confluent/ConfluentTranslator/public/admin.html
StillHammer f2143bb10b WIP: Custom API keys + rate limiter fixes (à continuer)
- 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>
2025-12-02 16:40:48 +08:00

657 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>