Implement comprehensive API token management system

- Create dedicated API token generation page with custom naming
- Implement JWT token generation with expiration and user claims
- Add token administration with list view, last-used tracking, and deletion
- Update database schema to support token names and usage tracking
- Add navigation links for API tokens page with proper access control
- Implement secure token validation with automatic last-used timestamp updates
- Add copy-to-clipboard functionality for generated tokens
- Create user-friendly token management interface with confirmation dialogs
parent 26bf985b
......@@ -56,7 +56,7 @@
<a href="/analyze">Analyze</a>
<a href="/train">Train</a>
<a href="/history">History</a>
<a href="/settings">Settings</a>
<a href="/api_tokens">API Tokens</a>
<a href="/admin" class="active">Admin</a>
</nav>
<div class="user-menu">
......
......@@ -178,7 +178,10 @@
<a href="/analyze" class="active">Analyze</a>
<a href="/train">Train</a>
<a href="/history">History</a>
<a href="/settings">Settings</a>
<a href="/api_tokens">API Tokens</a>
{% if user.get('role') == 'admin' %}
<a href="/admin">Admin</a>
{% endif %}
</nav>
<div class="user-menu">
<span>{{ tokens }} tokens</span>
......
<!DOCTYPE html>
<html>
<head>
<title>API Tokens - VidAI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #f8fafc; }
.header { background: white; padding: 1rem 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.header-content { display: flex; justify-content: space-between; align-items: center; max-width: 1200px; margin: 0 auto; }
.logo { font-size: 1.5rem; font-weight: 700; color: #667eea; }
.nav { display: flex; gap: 2rem; }
.nav a { text-decoration: none; color: #64748b; font-weight: 500; }
.nav a.active { color: #667eea; }
.user-menu { display: flex; align-items: center; gap: 1rem; }
.container { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; }
.tokens-card { background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 2rem; }
.card-header { margin-bottom: 1.5rem; }
.card-header h3 { margin: 0; color: #1e293b; }
.form-group { margin-bottom: 1.5rem; }
.form-group label { display: block; margin-bottom: 0.5rem; color: #374151; font-weight: 500; }
.form-group input { width: 100%; padding: 0.75rem; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 1rem; }
.form-group input:focus { outline: none; border-color: #667eea; }
.btn { padding: 0.75rem 2rem; background: #667eea; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; }
.btn:hover { background: #5a67d8; }
.btn-danger { background: #dc2626; }
.btn-danger:hover { background: #b91c1c; }
.alert { padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; }
.alert-error { background: #fee2e2; color: #dc2626; border: 1px solid #fecaca; }
.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
.token-display { background: #f8fafc; padding: 1rem; border-radius: 8px; border: 1px solid #e5e7eb; margin: 1rem 0; }
.token-display pre { word-break: break-all; white-space: pre-wrap; font-family: monospace; background: white; padding: 1rem; border-radius: 4px; border: 1px solid #d1d5db; }
.copy-btn { margin-top: 0.5rem; padding: 0.5rem 1rem; font-size: 0.9rem; }
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.table th, .table td { padding: 1rem; text-align: left; border-bottom: 1px solid #e5e7eb; }
.table th { background: #f8fafc; font-weight: 600; color: #374151; }
.status-active { color: #065f46; font-weight: 500; }
.status-inactive { color: #dc2626; font-weight: 500; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); }
.modal-content { background-color: white; margin: 15% auto; padding: 2rem; width: 90%; max-width: 500px; border-radius: 12px; }
.modal-header { margin-bottom: 1rem; }
.modal-footer { text-align: right; margin-top: 1.5rem; }
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<div class="logo">VidAI</div>
<nav class="nav">
<a href="/dashboard">Dashboard</a>
<a href="/analyze">Analyze</a>
<a href="/train">Train</a>
<a href="/history">History</a>
<a href="/api_tokens" class="active">API Tokens</a>
{% if user.get('role') == 'admin' %}
<a href="/admin">Admin</a>
{% endif %}
</nav>
<div class="user-menu">
<span>{{ user.get('username', 'User') }}</span>
<a href="/logout" style="color: #dc2626;">Logout</a>
</div>
</div>
</header>
<div class="container">
<div class="tokens-card">
<div class="card-header">
<h3><i class="fas fa-plus"></i> Generate New API Token</h3>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'error' if category == 'error' else 'success' }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if generated_token %}
<div class="alert alert-success">
<strong>Token Generated Successfully!</strong><br>
Copy the token below. You won't be able to see it again.
</div>
<div class="token-display">
<strong>Token Name:</strong> {{ token_name }}<br>
<strong>Token:</strong>
<pre id="tokenText">{{ generated_token }}</pre>
<button class="btn copy-btn" onclick="copyToken()">Copy Token</button>
</div>
{% endif %}
<form method="post" action="/api_tokens/generate">
<div class="form-group">
<label for="token_name">Token Name</label>
<input type="text" id="token_name" name="token_name" placeholder="e.g., My App Token" required>
</div>
<button type="submit" class="btn">Generate Token</button>
</form>
</div>
<div class="tokens-card">
<div class="card-header">
<h3><i class="fas fa-key"></i> Your API Tokens</h3>
</div>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in user_tokens %}
<tr>
<td>{{ token.get('name') }}</td>
<td>{{ token.get('created_at', 'N/A')[:19] if token.get('created_at') else 'N/A' }}</td>
<td>{{ token.get('last_used', 'Never')[:19] if token.get('last_used') else 'Never' }}</td>
<td><span class="status-{{ 'active' if token.get('active') else 'inactive' }}">{{ 'Active' if token.get('active') else 'Inactive' }}</span></td>
<td>
<button onclick="deleteToken({{ token.get('id') }}, '{{ token.get('name') }}')" class="btn btn-danger" style="padding: 0.5rem 1rem; font-size: 0.9rem;">Delete</button>
</td>
</tr>
{% endfor %}
{% if not user_tokens %}
<tr>
<td colspan="5" style="text-align: center; color: #6b7280;">No API tokens found. Generate your first token above.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Delete API Token</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the token "<span id="deleteTokenName"></span>"?</p>
<p style="color: #dc2626; font-weight: 500;">This action cannot be undone. Any applications using this token will stop working.</p>
</div>
<div class="modal-footer">
<button onclick="closeDeleteModal()" class="btn" style="background: #6b7280;">Cancel</button>
<form id="deleteForm" method="post" action="" style="display: inline;">
<button type="submit" class="btn btn-danger">Delete Token</button>
</form>
</div>
</div>
</div>
<script>
function copyToken() {
const tokenText = document.getElementById('tokenText');
navigator.clipboard.writeText(tokenText.textContent).then(function() {
alert('Token copied to clipboard!');
}, function(err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = tokenText.textContent;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert('Token copied to clipboard!');
});
}
function deleteToken(tokenId, tokenName) {
document.getElementById('deleteTokenName').textContent = tokenName;
document.getElementById('deleteForm').action = `/api_tokens/delete/${tokenId}`;
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
</script>
</body>
</html>
\ No newline at end of file
......@@ -351,9 +351,11 @@ def init_db(conn) -> None:
CREATE TABLE IF NOT EXISTS user_api_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
token TEXT NOT NULL,
active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
''')
......@@ -362,9 +364,11 @@ def init_db(conn) -> None:
CREATE TABLE IF NOT EXISTS user_api_tokens (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
......@@ -1064,18 +1068,31 @@ def create_worker_token() -> str:
return token
def create_user_api_token(user_id: int) -> str:
def create_user_api_token(user_id: int, name: str) -> str:
"""Create a user API token for programmatic access."""
import jwt
import time
import secrets
token = secrets.token_hex(32)
# Create JWT payload
payload = {
'user_id': user_id,
'token_id': secrets.token_hex(16),
'iat': int(time.time()),
'exp': int(time.time()) + (365 * 24 * 60 * 60) # 1 year expiration
}
# Use a simple secret key (in production, use environment variable)
secret_key = os.environ.get('JWT_SECRET_KEY', 'vidai-jwt-secret-key-change-in-production')
token = jwt.encode(payload, secret_key, algorithm='HS256')
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO user_api_tokens (user_id, token, created_at, active)
VALUES (?, ?, datetime('now'), 1)
''', (user_id, token))
INSERT INTO user_api_tokens (user_id, name, token, created_at, active)
VALUES (?, ?, ?, datetime('now'), 1)
''', (user_id, name, token))
conn.commit()
conn.close()
......@@ -1084,15 +1101,60 @@ def create_user_api_token(user_id: int) -> str:
def validate_user_api_token(token: str) -> Optional[Dict[str, Any]]:
"""Validate user API token and return user info."""
import jwt
# Decode JWT token
try:
secret_key = os.environ.get('JWT_SECRET_KEY', 'vidai-jwt-secret-key-change-in-production')
payload = jwt.decode(token, secret_key, algorithms=['HS256'])
user_id = payload['user_id']
token_id = payload['token_id']
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
conn = get_db_connection()
cursor = conn.cursor()
# Update last used timestamp and get user info
cursor.execute('''
UPDATE user_api_tokens SET last_used = datetime('now')
WHERE user_id = ? AND token = ? AND active = 1
''', (user_id, token))
cursor.execute('''
SELECT u.* FROM user_api_tokens t
JOIN users u ON t.user_id = u.id
WHERE t.token = ? AND t.active = 1 AND u.active = 1
''', (token,))
WHERE t.user_id = ? AND t.token = ? AND t.active = 1 AND u.active = 1
''', (user_id, token))
row = cursor.fetchone()
conn.commit()
conn.close()
return dict(row) if row else None
def get_user_api_tokens(user_id: int) -> List[Dict[str, Any]]:
"""Get all API tokens for a user."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT id, name, created_at, last_used, active
FROM user_api_tokens
WHERE user_id = ? ORDER BY created_at DESC
''', (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def delete_user_api_token(user_id: int, token_id: int) -> bool:
"""Delete a user API token."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM user_api_tokens WHERE id = ? AND user_id = ?', (token_id, user_id))
conn.commit()
success = cursor.rowcount > 0
conn.close()
return dict(row) if row else None
\ No newline at end of file
return success
\ No newline at end of file
......@@ -361,15 +361,44 @@ def generate_worker_token():
flash(f'New worker token generated: {token[:20]}...', 'success')
return redirect(url_for('settings'))
@app.route('/generate_api_token')
@app.route('/api_tokens')
@login_required
def api_tokens():
"""API token management page."""
user = get_current_user_session()
from .database import get_user_api_tokens
user_tokens = get_user_api_tokens(user['id'])
return render_template('api_tokens.html', user=user, user_tokens=user_tokens)
@app.route('/api_tokens/generate', methods=['POST'])
@login_required
def generate_api_token():
"""Generate a new API token for programmatic access."""
user = get_current_user_session()
from .auth import generate_user_api_token
token = generate_user_api_token(user['id'])
flash(f'New API token generated: {token[:20]}...', 'success')
return redirect(url_for('settings'))
token_name = request.form.get('token_name', '').strip()
if not token_name:
flash('Token name is required', 'error')
return redirect(url_for('api_tokens'))
from .database import create_user_api_token
token = create_user_api_token(user['id'], token_name)
flash('API token generated successfully!', 'success')
return render_template('api_tokens.html', user=user, user_tokens=[], generated_token=token, token_name=token_name)
@app.route('/api_tokens/delete/<int:token_id>', methods=['POST'])
@login_required
def delete_api_token(token_id):
"""Delete an API token."""
user = get_current_user_session()
from .database import delete_user_api_token
if delete_user_api_token(user['id'], token_id):
flash('API token deleted successfully!', 'success')
else:
flash('Failed to delete API token.', 'error')
return redirect(url_for('api_tokens'))
@app.route('/update_database_settings', methods=['POST'])
@admin_required
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment