- Ajout support custom API keys (Anthropic/OpenAI) dans localStorage - Backend utilise custom keys si fournis (pas de déduction rate limit) - Tentative fix rate limiter pour /api/llm/limit (skip globalLimiter) - Fix undefined/undefined dans compteur requêtes - Ajout error loop prevention (stop après 5 erreurs) - Reset quotidien à minuit pour compteur LLM Note: Problème 429 persiste, à débugger à la maison 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
657 lines
16 KiB
HTML
657 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Admin - ConfluentTranslator</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: #1a1a1a;
|
||
color: #e0e0e0;
|
||
padding: 20px;
|
||
line-height: 1.6;
|
||
/* Caché par défaut jusqu'à vérification admin */
|
||
visibility: hidden;
|
||
}
|
||
body.authorized {
|
||
visibility: visible;
|
||
}
|
||
.container { max-width: 1200px; margin: 0 auto; }
|
||
|
||
h1 {
|
||
color: #4a9eff;
|
||
margin-bottom: 10px;
|
||
font-size: 2em;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #888;
|
||
margin-bottom: 30px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.panel {
|
||
background: #2a2a2a;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
border: 1px solid #3a3a3a;
|
||
}
|
||
|
||
.panel h2 {
|
||
color: #4a9eff;
|
||
margin-bottom: 15px;
|
||
font-size: 1.2em;
|
||
}
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 15px;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.stat-box {
|
||
background: #2a2a2a;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
border-left: 4px solid #4a9eff;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 2em;
|
||
color: #4a9eff;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.stat-label {
|
||
color: #888;
|
||
font-size: 0.9em;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
button {
|
||
background: #4a9eff;
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 20px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.95em;
|
||
font-weight: 600;
|
||
transition: background 0.2s;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
button:hover { background: #357abd; }
|
||
button:disabled { background: #555; cursor: not-allowed; }
|
||
|
||
button.danger {
|
||
background: #dc3545;
|
||
}
|
||
button.danger:hover {
|
||
background: #c82333;
|
||
}
|
||
|
||
button.secondary {
|
||
background: #6c757d;
|
||
}
|
||
button.secondary:hover {
|
||
background: #5a6268;
|
||
}
|
||
|
||
input, select {
|
||
width: 100%;
|
||
padding: 10px;
|
||
background: #1a1a1a;
|
||
border: 1px solid #3a3a3a;
|
||
border-radius: 4px;
|
||
color: #e0e0e0;
|
||
font-family: inherit;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
color: #b0b0b0;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.token-list {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.token-item {
|
||
background: #1a1a1a;
|
||
padding: 15px;
|
||
border-radius: 4px;
|
||
margin-bottom: 10px;
|
||
border-left: 3px solid #4a9eff;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.token-item.disabled {
|
||
opacity: 0.5;
|
||
border-left-color: #dc3545;
|
||
}
|
||
|
||
.token-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.token-id {
|
||
font-family: monospace;
|
||
color: #4a9eff;
|
||
font-size: 0.85em;
|
||
margin-bottom: 5px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.token-details {
|
||
font-size: 0.9em;
|
||
color: #888;
|
||
}
|
||
|
||
.token-details span {
|
||
margin-right: 15px;
|
||
}
|
||
|
||
.token-actions {
|
||
display: flex;
|
||
gap: 5px;
|
||
}
|
||
|
||
.token-actions button {
|
||
padding: 6px 12px;
|
||
font-size: 0.85em;
|
||
margin: 0;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 3px 8px;
|
||
border-radius: 3px;
|
||
font-size: 0.75em;
|
||
font-weight: bold;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.badge.admin {
|
||
background: #4a9eff;
|
||
color: white;
|
||
}
|
||
|
||
.badge.user {
|
||
background: #6c757d;
|
||
color: white;
|
||
}
|
||
|
||
.badge.disabled {
|
||
background: #dc3545;
|
||
color: white;
|
||
}
|
||
|
||
.message {
|
||
padding: 12px 16px;
|
||
border-radius: 4px;
|
||
margin-bottom: 15px;
|
||
display: none;
|
||
}
|
||
|
||
.message.success {
|
||
background: #28a745;
|
||
color: white;
|
||
}
|
||
|
||
.message.error {
|
||
background: #dc3545;
|
||
color: white;
|
||
}
|
||
|
||
.message.info {
|
||
background: #17a2b8;
|
||
color: white;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.copy-btn {
|
||
background: #28a745;
|
||
padding: 4px 8px;
|
||
font-size: 0.8em;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.copy-btn:hover {
|
||
background: #218838;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal.show {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-content {
|
||
background: #2a2a2a;
|
||
padding: 30px;
|
||
border-radius: 8px;
|
||
max-width: 600px;
|
||
width: 90%;
|
||
border: 2px solid #4a9eff;
|
||
}
|
||
|
||
.modal-header {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
color: #4a9eff;
|
||
}
|
||
|
||
.modal-footer {
|
||
margin-top: 20px;
|
||
text-align: right;
|
||
}
|
||
|
||
.new-token-display {
|
||
background: #1a1a1a;
|
||
padding: 15px;
|
||
border-radius: 4px;
|
||
border: 2px solid #28a745;
|
||
margin: 15px 0;
|
||
font-family: monospace;
|
||
word-break: break-all;
|
||
color: #28a745;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.warning-text {
|
||
color: #ffc107;
|
||
font-weight: bold;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.logout-btn {
|
||
background: #6c757d;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #888;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header-actions">
|
||
<div>
|
||
<h1>🔐 Administration</h1>
|
||
<div class="subtitle">Gestion des tokens API - ConfluentTranslator</div>
|
||
</div>
|
||
<button class="logout-btn" onclick="logout()">← Retour à l'app</button>
|
||
</div>
|
||
|
||
<div id="message-container"></div>
|
||
|
||
<!-- Stats -->
|
||
<div class="stats">
|
||
<div class="stat-box">
|
||
<div class="stat-value" id="stat-total">-</div>
|
||
<div class="stat-label">Total Tokens</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-value" id="stat-active">-</div>
|
||
<div class="stat-label">Actifs</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-value" id="stat-admins">-</div>
|
||
<div class="stat-label">Admins</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-value" id="stat-requests">-</div>
|
||
<div class="stat-label">Requêtes (24h)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Token -->
|
||
<div class="panel">
|
||
<h2>➕ Créer un nouveau token</h2>
|
||
<div class="form-group">
|
||
<label>Nom / Description</label>
|
||
<input type="text" id="new-token-name" placeholder="ex: user-frontend, api-mobile...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Rôle</label>
|
||
<select id="new-token-role">
|
||
<option value="user">User (accès standard)</option>
|
||
<option value="admin">Admin (accès complet)</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="createToken()">Créer le token</button>
|
||
</div>
|
||
|
||
<!-- Token List -->
|
||
<div class="panel">
|
||
<h2>📋 Tokens existants</h2>
|
||
<div id="token-list" class="token-list">
|
||
<div class="loading">Chargement des tokens...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal pour afficher le nouveau token -->
|
||
<div id="new-token-modal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>✅ Token créé avec succès</h2>
|
||
</div>
|
||
<div>
|
||
<p style="margin-bottom: 10px;">Voici votre nouveau token :</p>
|
||
<div class="new-token-display" id="new-token-value"></div>
|
||
<p class="warning-text">⚠️ Copiez ce token maintenant ! Il ne sera plus affiché.</p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="copy-btn" onclick="copyNewToken()">📋 Copier</button>
|
||
<button onclick="closeModal()">Fermer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API_KEY_STORAGE = 'confluentApiKey';
|
||
|
||
// Get API key
|
||
const getApiKey = () => localStorage.getItem(API_KEY_STORAGE);
|
||
|
||
// Check admin access
|
||
const checkAdminAccess = async () => {
|
||
const apiKey = getApiKey();
|
||
if (!apiKey) {
|
||
window.location.href = '/';
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/validate', {
|
||
headers: { 'x-api-key': apiKey }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
showMessage('Accès refusé. Token invalide.', 'error');
|
||
setTimeout(() => window.location.href = '/', 2000);
|
||
return false;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data.role !== 'admin') {
|
||
setTimeout(() => window.location.href = '/', 100);
|
||
return false;
|
||
}
|
||
|
||
// Autorisé - afficher la page
|
||
document.body.classList.add('authorized');
|
||
return true;
|
||
} catch (error) {
|
||
showMessage('Erreur de connexion', 'error');
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Authenticated fetch
|
||
const authFetch = async (url, options = {}) => {
|
||
const apiKey = getApiKey();
|
||
const response = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
...options.headers,
|
||
'x-api-key': apiKey
|
||
}
|
||
});
|
||
|
||
if (response.status === 401 || response.status === 403) {
|
||
window.location.href = '/';
|
||
throw new Error('Session expirée');
|
||
}
|
||
|
||
return response;
|
||
};
|
||
|
||
// Show message
|
||
const showMessage = (text, type = 'info') => {
|
||
const container = document.getElementById('message-container');
|
||
const message = document.createElement('div');
|
||
message.className = `message ${type}`;
|
||
message.textContent = text;
|
||
message.style.display = 'block';
|
||
container.appendChild(message);
|
||
|
||
setTimeout(() => {
|
||
message.remove();
|
||
}, 5000);
|
||
};
|
||
|
||
// Load stats
|
||
const loadStats = async () => {
|
||
try {
|
||
const response = await authFetch('/api/admin/stats');
|
||
const data = await response.json();
|
||
|
||
const tokenStats = data.tokens || {};
|
||
const logStats = data.logs || {};
|
||
|
||
document.getElementById('stat-total').textContent = tokenStats.totalTokens || 0;
|
||
document.getElementById('stat-active').textContent = tokenStats.activeTokens || 0;
|
||
document.getElementById('stat-admins').textContent = '?'; // Not in current stats
|
||
document.getElementById('stat-requests').textContent = logStats.totalRequests || 0;
|
||
} catch (error) {
|
||
console.error('Error loading stats:', error);
|
||
}
|
||
};
|
||
|
||
// Load tokens
|
||
const loadTokens = async () => {
|
||
try {
|
||
const response = await authFetch('/api/admin/tokens');
|
||
const data = await response.json();
|
||
const tokens = data.tokens || data; // Support both formats
|
||
|
||
const container = document.getElementById('token-list');
|
||
container.innerHTML = '';
|
||
|
||
if (tokens.length === 0) {
|
||
container.innerHTML = '<div class="loading">Aucun token trouvé</div>';
|
||
return;
|
||
}
|
||
|
||
tokens.forEach(token => {
|
||
const item = document.createElement('div');
|
||
const tokenId = token.apiKey || token.id;
|
||
const isActive = token.active !== undefined ? token.active : token.enabled;
|
||
|
||
item.className = `token-item ${isActive ? '' : 'disabled'}`;
|
||
|
||
const enabledBadge = isActive ? '' : '<span class="badge disabled">Désactivé</span>';
|
||
const roleBadge = `<span class="badge ${token.role}">${token.role}</span>`;
|
||
|
||
item.innerHTML = `
|
||
<div class="token-info">
|
||
<div class="token-id">${tokenId}</div>
|
||
<div class="token-details">
|
||
<span>${roleBadge} ${enabledBadge}</span>
|
||
<span><strong>Nom:</strong> ${token.name}</span>
|
||
<span><strong>Créé:</strong> ${new Date(token.createdAt).toLocaleDateString('fr-FR')}</span>
|
||
</div>
|
||
</div>
|
||
<div class="token-actions">
|
||
${isActive ?
|
||
`<button class="secondary" onclick="disableToken('${tokenId}')">Désactiver</button>` :
|
||
`<button onclick="enableToken('${tokenId}')">Activer</button>`
|
||
}
|
||
<button class="danger" onclick="deleteToken('${tokenId}')">Supprimer</button>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(item);
|
||
});
|
||
} catch (error) {
|
||
console.error('Error loading tokens:', error);
|
||
showMessage('Erreur lors du chargement des tokens', 'error');
|
||
}
|
||
};
|
||
|
||
// Create token
|
||
const createToken = async () => {
|
||
const name = document.getElementById('new-token-name').value.trim();
|
||
const role = document.getElementById('new-token-role').value;
|
||
|
||
if (!name) {
|
||
showMessage('Veuillez entrer un nom pour le token', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await authFetch('/api/admin/tokens', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, role })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Erreur lors de la création');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Show token in modal - support both formats
|
||
const newToken = data.token?.apiKey || data.token || data.apiKey;
|
||
document.getElementById('new-token-value').textContent = newToken;
|
||
document.getElementById('new-token-modal').classList.add('show');
|
||
|
||
// Reset form
|
||
document.getElementById('new-token-name').value = '';
|
||
document.getElementById('new-token-role').value = 'user';
|
||
|
||
// Reload lists
|
||
await loadTokens();
|
||
await loadStats();
|
||
} catch (error) {
|
||
console.error('Error creating token:', error);
|
||
showMessage(error.message, 'error');
|
||
}
|
||
};
|
||
|
||
// Copy new token
|
||
const copyNewToken = () => {
|
||
const token = document.getElementById('new-token-value').textContent;
|
||
navigator.clipboard.writeText(token);
|
||
showMessage('Token copié dans le presse-papier', 'success');
|
||
};
|
||
|
||
// Close modal
|
||
const closeModal = () => {
|
||
document.getElementById('new-token-modal').classList.remove('show');
|
||
};
|
||
|
||
// Disable token
|
||
const disableToken = async (token) => {
|
||
if (!confirm('Désactiver ce token ?')) return;
|
||
|
||
try {
|
||
const response = await authFetch(`/api/admin/tokens/${token}/disable`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Erreur lors de la désactivation');
|
||
|
||
showMessage('Token désactivé avec succès', 'success');
|
||
await loadTokens();
|
||
await loadStats();
|
||
} catch (error) {
|
||
console.error('Error disabling token:', error);
|
||
showMessage(error.message, 'error');
|
||
}
|
||
};
|
||
|
||
// Enable token
|
||
const enableToken = async (token) => {
|
||
try {
|
||
const response = await authFetch(`/api/admin/tokens/${token}/enable`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Erreur lors de l\'activation');
|
||
|
||
showMessage('Token activé avec succès', 'success');
|
||
await loadTokens();
|
||
await loadStats();
|
||
} catch (error) {
|
||
console.error('Error enabling token:', error);
|
||
showMessage(error.message, 'error');
|
||
}
|
||
};
|
||
|
||
// Delete token
|
||
const deleteToken = async (token) => {
|
||
if (!confirm('⚠️ ATTENTION : Supprimer définitivement ce token ?\n\nCette action est irréversible.')) return;
|
||
|
||
try {
|
||
const response = await authFetch(`/api/admin/tokens/${token}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Erreur lors de la suppression');
|
||
|
||
showMessage('Token supprimé avec succès', 'success');
|
||
await loadTokens();
|
||
await loadStats();
|
||
} catch (error) {
|
||
console.error('Error deleting token:', error);
|
||
showMessage(error.message, 'error');
|
||
}
|
||
};
|
||
|
||
// Logout
|
||
const logout = () => {
|
||
window.location.href = '/';
|
||
};
|
||
|
||
// Initialize
|
||
(async () => {
|
||
const hasAccess = await checkAdminAccess();
|
||
if (hasAccess) {
|
||
await loadStats();
|
||
await loadTokens();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|