add video backend server, fix styling and more #54
+76
-25
@@ -172,30 +172,6 @@
|
|||||||
|
|
||||||
<div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab">
|
<div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-xl-8">
|
|
||||||
<div class="card p-3">
|
|
||||||
<h4 class="mb-1">Réinitialiser mot de passe utilisateur</h4>
|
|
||||||
<p class="text-muted mb-3">
|
|
||||||
Génère un mot de passe temporaire aléatoire. L'utilisateur devra choisir un nouveau mot de passe à sa prochaine connexion.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="user-reset-response" class="alert d-none" role="alert"></div>
|
|
||||||
|
|
||||||
<form id="reset-user-password-form">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="reset_user_email" class="form-label">Email utilisateur</label>
|
|
||||||
<input type="email" class="form-control" id="reset_user_email" placeholder="[email protected]" required>
|
|
||||||
<div class="form-text">
|
|
||||||
Important: le mot de passe temporaire est affiché une seule fois ici. Copie-le et envoie-le à l'utilisateur.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-end">
|
|
||||||
<button type="submit" class="btn btn-danger">Reset</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card p-3">
|
<div class="card p-3">
|
||||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
|
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
|
||||||
@@ -215,10 +191,11 @@
|
|||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th style="width: 1%;">Statut</th>
|
<th style="width: 1%;">Statut</th>
|
||||||
|
<th style="width: 1%;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="admin-users-body">
|
<tbody id="admin-users-body">
|
||||||
<tr><td colspan="3" class="text-muted">Charge la liste…</td></tr>
|
<tr><td colspan="4" class="text-muted">Charge la liste…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +317,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset user password modal -->
|
||||||
|
<div class="modal fade" id="resetUserPasswordModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Reset mot de passe</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
Génère un mot de passe temporaire. L'utilisateur devra le changer à sa prochaine connexion.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-semibold" id="reset_user_label">—</div>
|
||||||
|
<div class="text-muted small" id="reset_user_email_label">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reset-user-modal-response" class="alert d-none" role="alert"></div>
|
||||||
|
|
||||||
|
<div id="reset-user-modal-result" class="border rounded p-2 bg-light d-none">
|
||||||
|
<div class="small text-muted mb-1">Mot de passe temporaire (affiché une seule fois)</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<code class="flex-grow-1" id="reset-user-temp-password">—</code>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-temp-password">Copier</button>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-2" id="reset-user-expires-at"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Fermer</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirm-reset-user-password">Générer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete user confirmation modal -->
|
||||||
|
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title text-danger">Supprimer un utilisateur</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
Tu es sur le point de supprimer:
|
||||||
|
<div class="fw-semibold" id="delete_user_label">—</div>
|
||||||
|
<div class="text-muted small" id="delete_user_email_label">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
Cette action est <strong>irréversible</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
Pour confirmer, tape l'email exact:
|
||||||
|
</div>
|
||||||
|
<input type="text" class="form-control" id="delete_user_verify_input" placeholder="[email protected]">
|
||||||
|
<div class="form-text">
|
||||||
|
Le bouton “Supprimer” s'activera seulement si l'email correspond.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="delete-user-modal-response" class="alert d-none mt-3" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirm-delete-user" disabled>Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit movie modal -->
|
<!-- Edit movie modal -->
|
||||||
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
|||||||
+180
-28
@@ -212,23 +212,79 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userResetResponseEl = document.getElementById('user-reset-response');
|
|
||||||
const userResetForm = document.getElementById('reset-user-password-form');
|
|
||||||
const userResetEmailEl = document.getElementById('reset_user_email');
|
|
||||||
|
|
||||||
// Admin users list elements
|
// Admin users list elements
|
||||||
const adminUsersBody = document.getElementById('admin-users-body');
|
const adminUsersBody = document.getElementById('admin-users-body');
|
||||||
const refreshUsersBtn = document.getElementById('refresh-users');
|
const refreshUsersBtn = document.getElementById('refresh-users');
|
||||||
const adminUsersCountEl = document.getElementById('admin-users-count');
|
const adminUsersCountEl = document.getElementById('admin-users-count');
|
||||||
const adminUsersTabBtn = document.getElementById('admin-users-tab');
|
const adminUsersTabBtn = document.getElementById('admin-users-tab');
|
||||||
|
|
||||||
let usersLoadedOnce = false;
|
// Admin user modals
|
||||||
|
const resetUserPasswordModalEl = document.getElementById('resetUserPasswordModal');
|
||||||
|
const resetUserPasswordModal = resetUserPasswordModalEl ? new bootstrap.Modal(resetUserPasswordModalEl) : null;
|
||||||
|
const resetUserLabelEl = document.getElementById('reset_user_label');
|
||||||
|
const resetUserEmailLabelEl = document.getElementById('reset_user_email_label');
|
||||||
|
const resetUserModalResponseEl = document.getElementById('reset-user-modal-response');
|
||||||
|
const resetUserModalResultEl = document.getElementById('reset-user-modal-result');
|
||||||
|
const resetUserTempPasswordEl = document.getElementById('reset-user-temp-password');
|
||||||
|
const resetUserExpiresAtEl = document.getElementById('reset-user-expires-at');
|
||||||
|
const confirmResetUserPasswordBtn = document.getElementById('confirm-reset-user-password');
|
||||||
|
const copyTempPasswordBtn = document.getElementById('copy-temp-password');
|
||||||
|
|
||||||
function showUserResetResponse(kind, message) {
|
const deleteUserModalEl = document.getElementById('deleteUserModal');
|
||||||
if (!userResetResponseEl) return;
|
const deleteUserModal = deleteUserModalEl ? new bootstrap.Modal(deleteUserModalEl) : null;
|
||||||
userResetResponseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
|
const deleteUserLabelEl = document.getElementById('delete_user_label');
|
||||||
userResetResponseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
|
const deleteUserEmailLabelEl = document.getElementById('delete_user_email_label');
|
||||||
userResetResponseEl.textContent = message;
|
const deleteUserVerifyInputEl = document.getElementById('delete_user_verify_input');
|
||||||
|
const deleteUserModalResponseEl = document.getElementById('delete-user-modal-response');
|
||||||
|
const confirmDeleteUserBtn = document.getElementById('confirm-delete-user');
|
||||||
|
|
||||||
|
let usersLoadedOnce = false;
|
||||||
|
let currentResetTarget = null;
|
||||||
|
let currentDeleteTarget = null;
|
||||||
|
|
||||||
|
function setAlert(el, kind, message) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
|
||||||
|
el.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
|
||||||
|
el.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAlert(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('d-none');
|
||||||
|
el.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetResetUserModalUi() {
|
||||||
|
hideAlert(resetUserModalResponseEl);
|
||||||
|
if (resetUserModalResultEl) resetUserModalResultEl.classList.add('d-none');
|
||||||
|
if (resetUserTempPasswordEl) resetUserTempPasswordEl.textContent = '—';
|
||||||
|
if (resetUserExpiresAtEl) resetUserExpiresAtEl.textContent = '';
|
||||||
|
if (confirmResetUserPasswordBtn) confirmResetUserPasswordBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetUserModal(user) {
|
||||||
|
currentResetTarget = user || null;
|
||||||
|
if (!resetUserPasswordModal) return;
|
||||||
|
resetResetUserModalUi();
|
||||||
|
if (resetUserLabelEl) resetUserLabelEl.textContent = user?.name ? user.name : '(sans nom)';
|
||||||
|
if (resetUserEmailLabelEl) resetUserEmailLabelEl.textContent = user?.email || '';
|
||||||
|
resetUserPasswordModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDeleteUserModalUi() {
|
||||||
|
hideAlert(deleteUserModalResponseEl);
|
||||||
|
if (deleteUserVerifyInputEl) deleteUserVerifyInputEl.value = '';
|
||||||
|
if (confirmDeleteUserBtn) confirmDeleteUserBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteUserModal(user) {
|
||||||
|
currentDeleteTarget = user || null;
|
||||||
|
if (!deleteUserModal) return;
|
||||||
|
resetDeleteUserModalUi();
|
||||||
|
if (deleteUserLabelEl) deleteUserLabelEl.textContent = user?.name ? user.name : '(sans nom)';
|
||||||
|
if (deleteUserEmailLabelEl) deleteUserEmailLabelEl.textContent = user?.email || '';
|
||||||
|
deleteUserModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUsersCount(count) {
|
function setUsersCount(count) {
|
||||||
@@ -242,7 +298,7 @@ window.onload = async function () {
|
|||||||
setUsersCount(list.length);
|
setUsersCount(list.length);
|
||||||
|
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
adminUsersBody.innerHTML = '<tr><td colspan="3" class="text-muted">Aucun utilisateur.</td></tr>';
|
adminUsersBody.innerHTML = '<tr><td colspan="4" class="text-muted">Aucun utilisateur.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,9 +337,34 @@ window.onload = async function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tdActions = document.createElement('td');
|
||||||
|
tdActions.className = 'text-nowrap';
|
||||||
|
|
||||||
|
const resetBtn = document.createElement('button');
|
||||||
|
resetBtn.type = 'button';
|
||||||
|
resetBtn.className = 'btn btn-outline-danger btn-sm me-1';
|
||||||
|
resetBtn.textContent = 'Reset MDP';
|
||||||
|
resetBtn.addEventListener('click', () => openResetUserModal(u));
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.type = 'button';
|
||||||
|
deleteBtn.className = 'btn btn-outline-danger btn-sm';
|
||||||
|
deleteBtn.textContent = 'Supprimer';
|
||||||
|
|
||||||
|
const isSelf = String(u?.id || '') && String(u.id) === String(decoded?.id || '');
|
||||||
|
if (isSelf || u?.admin) {
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.title = isSelf ? 'Impossible de supprimer ton propre compte' : 'Suppression des admins désactivée';
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', () => openDeleteUserModal(u));
|
||||||
|
tdActions.appendChild(resetBtn);
|
||||||
|
tdActions.appendChild(deleteBtn);
|
||||||
|
|
||||||
tr.appendChild(tdName);
|
tr.appendChild(tdName);
|
||||||
tr.appendChild(tdEmail);
|
tr.appendChild(tdEmail);
|
||||||
tr.appendChild(tdStatus);
|
tr.appendChild(tdStatus);
|
||||||
|
tr.appendChild(tdActions);
|
||||||
adminUsersBody.appendChild(tr);
|
adminUsersBody.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,7 +390,7 @@ window.onload = async function () {
|
|||||||
if (!adminUsersBody) return;
|
if (!adminUsersBody) return;
|
||||||
if (usersLoadedOnce && !force) return;
|
if (usersLoadedOnce && !force) return;
|
||||||
|
|
||||||
adminUsersBody.innerHTML = '<tr><td colspan="3" class="text-muted">Chargement…</td></tr>';
|
adminUsersBody.innerHTML = '<tr><td colspan="4" class="text-muted">Chargement…</td></tr>';
|
||||||
setUsersCount(null);
|
setUsersCount(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -318,7 +399,7 @@ window.onload = async function () {
|
|||||||
users.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' }));
|
users.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' }));
|
||||||
renderAdminUsers(users);
|
renderAdminUsers(users);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
adminUsersBody.innerHTML = `<tr><td colspan="3" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
adminUsersBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
||||||
setUsersCount(null);
|
setUsersCount(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,37 +508,108 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userResetForm && userResetEmailEl) {
|
if (deleteUserVerifyInputEl && confirmDeleteUserBtn) {
|
||||||
userResetForm.addEventListener('submit', async function (e) {
|
deleteUserVerifyInputEl.addEventListener('input', () => {
|
||||||
e.preventDefault();
|
const expected = String(currentDeleteTarget?.email || '').trim();
|
||||||
const email = (userResetEmailEl.value || '').trim();
|
const typed = String(deleteUserVerifyInputEl.value || '').trim();
|
||||||
if (!email) {
|
confirmDeleteUserBtn.disabled = !(expected && typed && expected === typed);
|
||||||
showUserResetResponse('warning', 'Email invalide.');
|
});
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
if (copyTempPasswordBtn) {
|
||||||
|
copyTempPasswordBtn.addEventListener('click', async () => {
|
||||||
|
const text = String(resetUserTempPasswordEl?.textContent || '').trim();
|
||||||
|
if (!text || text === '—') return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setAlert(resetUserModalResponseEl, 'success', 'Copié.');
|
||||||
|
setTimeout(() => hideAlert(resetUserModalResponseEl), 1200);
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback: select text for manual copy
|
||||||
|
setAlert(resetUserModalResponseEl, 'warning', 'Impossible de copier automatiquement. Copie manuellement.');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmResetUserPasswordBtn) {
|
||||||
|
confirmResetUserPasswordBtn.addEventListener('click', async () => {
|
||||||
|
const target = currentResetTarget;
|
||||||
|
const userId = String(target?.id || '').trim();
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
confirmResetUserPasswordBtn.disabled = true;
|
||||||
|
resetResetUserModalUi();
|
||||||
|
setAlert(resetUserModalResponseEl, 'warning', 'Génération du mot de passe temporaire...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showUserResetResponse('warning', 'Génération du mot de passe temporaire...');
|
|
||||||
const res = await fetch('/api/users/admin/reset-temp-password', {
|
const res = await fetch('/api/users/admin/reset-temp-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ userId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showUserResetResponse('danger', data.message || data.error || `Erreur (${res.status})`);
|
setAlert(resetUserModalResponseEl, 'danger', data.message || data.error || `Erreur (${res.status})`);
|
||||||
|
confirmResetUserPasswordBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = data?.user?.name ? `${data.user.name} (${data.user.email})` : email;
|
hideAlert(resetUserModalResponseEl);
|
||||||
const expiresAt = data?.expiresAt ? ` Expire: ${data.expiresAt}` : '';
|
if (resetUserTempPasswordEl) resetUserTempPasswordEl.textContent = data?.tempPassword || '—';
|
||||||
showUserResetResponse('success', `Temp password pour ${label}: ${data.tempPassword}.${expiresAt}`);
|
if (resetUserExpiresAtEl) {
|
||||||
|
resetUserExpiresAtEl.textContent = data?.expiresAt ? `Expire: ${data.expiresAt}` : '';
|
||||||
|
}
|
||||||
|
if (resetUserModalResultEl) resetUserModalResultEl.classList.remove('d-none');
|
||||||
|
setAlert(resetUserModalResponseEl, 'success', 'Mot de passe temporaire généré. Copie-le et envoie-le à l’utilisateur.');
|
||||||
|
confirmResetUserPasswordBtn.disabled = false;
|
||||||
|
await loadAdminUsers({ force: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showUserResetResponse('danger', err.message || 'Erreur réseau');
|
setAlert(resetUserModalResponseEl, 'danger', err.message || 'Erreur réseau');
|
||||||
|
confirmResetUserPasswordBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDeleteUserBtn) {
|
||||||
|
confirmDeleteUserBtn.addEventListener('click', async () => {
|
||||||
|
const target = currentDeleteTarget;
|
||||||
|
const userId = String(target?.id || '').trim();
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
confirmDeleteUserBtn.disabled = true;
|
||||||
|
hideAlert(deleteUserModalResponseEl);
|
||||||
|
setAlert(deleteUserModalResponseEl, 'warning', 'Suppression...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/admin/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setAlert(deleteUserModalResponseEl, 'danger', data.message || data.error || `Erreur (${res.status})`);
|
||||||
|
// Keep disabled until the input matches again.
|
||||||
|
const expected = String(target?.email || '').trim();
|
||||||
|
const typed = String(deleteUserVerifyInputEl?.value || '').trim();
|
||||||
|
confirmDeleteUserBtn.disabled = !(expected && typed && expected === typed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlert(deleteUserModalResponseEl, 'success', data.message || 'Utilisateur supprimé.');
|
||||||
|
await loadAdminUsers({ force: true });
|
||||||
|
// Close shortly after success
|
||||||
|
setTimeout(() => {
|
||||||
|
try { deleteUserModal?.hide(); } catch (_) {}
|
||||||
|
}, 600);
|
||||||
|
} catch (err) {
|
||||||
|
setAlert(deleteUserModalResponseEl, 'danger', err.message || 'Erreur réseau');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-7
@@ -45,7 +45,12 @@ function hasAnySource({ vod_link, video_src, embed_src }) {
|
|||||||
|
|
||||||
// Fetch movie details from OMDb API
|
// Fetch movie details from OMDb API
|
||||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
||||||
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
|
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
|
||||||
|
if (!apiKey) {
|
||||||
|
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
|
||||||
|
err.code = "OMDB_NOT_CONFIGURED";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -204,7 +209,22 @@ router.post("/add", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch movie details from OMDb API
|
// Fetch movie details from OMDb API
|
||||||
const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
|
let movieDetails;
|
||||||
|
try {
|
||||||
|
movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
|
||||||
|
} catch (err) {
|
||||||
|
// If OMDb isn't configured, keep the feature usable: add with placeholders so admin can fill manually later.
|
||||||
|
if (err?.code === "OMDB_NOT_CONFIGURED") {
|
||||||
|
movieDetails = {
|
||||||
|
title: String(imdb_id || "").trim() || "Untitled",
|
||||||
|
description: "",
|
||||||
|
rating: "",
|
||||||
|
poster: ""
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Proceed with adding the movie
|
// Proceed with adding the movie
|
||||||
const movie = new Movie({
|
const movie = new Movie({
|
||||||
@@ -363,11 +383,18 @@ router.put("/:id", async (req, res) => {
|
|||||||
|
|
||||||
// If refreshOmdb is true, overwrite OMDb-derived fields from the imdb_id
|
// If refreshOmdb is true, overwrite OMDb-derived fields from the imdb_id
|
||||||
if (refreshOmdb) {
|
if (refreshOmdb) {
|
||||||
const details = await fetchMovieDetailsFromOmdb(movie.imdb_id);
|
try {
|
||||||
movie.title = details.title;
|
const details = await fetchMovieDetailsFromOmdb(movie.imdb_id);
|
||||||
movie.description = details.description;
|
movie.title = details.title;
|
||||||
movie.rating = details.rating;
|
movie.description = details.description;
|
||||||
movie.poster = details.poster;
|
movie.rating = details.rating;
|
||||||
|
movie.poster = details.poster;
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.code === "OMDB_NOT_CONFIGURED") {
|
||||||
|
return res.status(503).json({ message: "OMDb n'est pas configuré (env OMDB_API manquante)." });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (title !== undefined) movie.title = title;
|
if (title !== undefined) movie.title = title;
|
||||||
if (description !== undefined) movie.description = description;
|
if (description !== undefined) movie.description = description;
|
||||||
|
|||||||
+60
-8
@@ -5,19 +5,28 @@ const crypto = require("crypto");
|
|||||||
const User = require("../models/User");
|
const User = require("../models/User");
|
||||||
const Movie = require("../models/Movie");
|
const Movie = require("../models/Movie");
|
||||||
const Setting = require("../models/Setting");
|
const Setting = require("../models/Setting");
|
||||||
|
const PlaybackProgress = require("../models/PlaybackProgress");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Set password for verification
|
// Set password for verification
|
||||||
|
|
||||||
const woof = process.env.DOG_NAMES ? process.env.DOG_NAMES.split(",") : [];
|
const woof =
|
||||||
|
typeof process.env.DOG_NAMES === "string"
|
||||||
|
? process.env.DOG_NAMES.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
||||||
|
|
||||||
// Fetch movie details from OMDb API
|
// Fetch movie details from OMDb API
|
||||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
||||||
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
|
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
|
||||||
|
if (!apiKey) {
|
||||||
|
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
|
||||||
|
err.code = "OMDB_NOT_CONFIGURED";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -161,7 +170,11 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
// Register User
|
// Register User
|
||||||
router.post("/register", async (req, res) => {
|
router.post("/register", async (req, res) => {
|
||||||
const { name, email, password, role, verifoof } = req.body;
|
const { name, email, password, role, verifoof } = req.body;
|
||||||
|
const dogCheckEnabled = woof.length > 0;
|
||||||
|
|
||||||
function registerVerification() {
|
function registerVerification() {
|
||||||
|
// If DOG_NAMES isn't configured, skip the "dog name" verification entirely.
|
||||||
|
if (!dogCheckEnabled) return true;
|
||||||
return woof.includes(verifoof);
|
return woof.includes(verifoof);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +182,16 @@ router.post("/register", async (req, res) => {
|
|||||||
if (!password) {
|
if (!password) {
|
||||||
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
||||||
}
|
}
|
||||||
if (!verifoof) {
|
if (dogCheckEnabled && !verifoof) {
|
||||||
throw new Error("Le nom du chien doit être défini" );
|
throw new Error("Le nom du chien doit être défini");
|
||||||
}
|
}
|
||||||
if (!registerVerification()) {
|
if (!registerVerification()) {
|
||||||
return res.status(201).json({ message: "Mauvais nom de chien" });
|
return res.status(400).json({ message: "Mauvais nom de chien" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await User.findOne({ email });
|
const existingUser = await User.findOne({ email });
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return res.status(201).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password and create user
|
// Hash password and create user
|
||||||
@@ -188,8 +201,7 @@ router.post("/register", async (req, res) => {
|
|||||||
|
|
||||||
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
||||||
res.status(403).json({ message: "Erreur interne lmao" });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,6 +298,46 @@ router.get("/admin/list", verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin: delete a user (and related playback progress)
|
||||||
|
router.post("/admin/delete", verifyToken, async (req, res) => {
|
||||||
|
if (!isAdminFromDecoded(req.user)) {
|
||||||
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, userId } = req.body || {};
|
||||||
|
const emailNorm = typeof email === "string" ? email.trim() : "";
|
||||||
|
const idNorm = typeof userId === "string" ? userId.trim() : "";
|
||||||
|
|
||||||
|
if (!emailNorm && !idNorm) {
|
||||||
|
return res.status(400).json({ message: "Email ou userId requis." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
|
||||||
|
if (!user) return res.status(404).json({ message: "User not found" });
|
||||||
|
|
||||||
|
// Safety: prevent deleting yourself from the admin UI.
|
||||||
|
if (String(user._id) === String(req.user?.id || "")) {
|
||||||
|
return res.status(400).json({ message: "Impossible de supprimer ton propre compte." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: prevent deleting admin accounts via the UI endpoint.
|
||||||
|
if (user.role === 69) {
|
||||||
|
return res.status(400).json({ message: "Impossible de supprimer un compte admin via cette interface." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await PlaybackProgress.deleteMany({ userId: user._id });
|
||||||
|
await User.deleteOne({ _id: user._id });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Utilisateur supprimé.",
|
||||||
|
user: { id: String(user._id), name: user.name, email: user.email },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// User: change password (used after temp-password login). Returns a fresh token.
|
// User: change password (used after temp-password login). Returns a fresh token.
|
||||||
router.post("/change-password", verifyToken, async (req, res) => {
|
router.post("/change-password", verifyToken, async (req, res) => {
|
||||||
const { newPassword } = req.body || {};
|
const { newPassword } = req.body || {};
|
||||||
|
|||||||
+79
-26
@@ -4,16 +4,65 @@ const moment = require("moment-timezone");
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
let latestCommitCache = null;
|
|
||||||
let lastFetchedTime = 0;
|
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
|
const TIMEZONE = "America/New_York";
|
||||||
|
|
||||||
|
// Cache is stored in the exact response shape expected by the client.
|
||||||
|
let latestCommitCache = null;
|
||||||
|
|
||||||
|
// Avoid noisy logs when GitHub isn't configured or is temporarily unavailable.
|
||||||
|
let lastLogTime = 0;
|
||||||
|
const LOG_THROTTLE_MS = 60 * 60 * 1000; // 1h
|
||||||
|
|
||||||
|
function nowFormatted() {
|
||||||
|
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGitHubConfig() {
|
||||||
|
const owner = typeof process.env.GITHUB_OWNER === "string" ? process.env.GITHUB_OWNER.trim() : "";
|
||||||
|
const repo = typeof process.env.GITHUB_REPO === "string" ? process.env.GITHUB_REPO.trim() : "";
|
||||||
|
return { owner, repo, configured: !!owner && !!repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
// routes/version.js -> ../package.json
|
||||||
|
const pkg = require("../package.json");
|
||||||
|
return typeof pkg?.version === "string" && pkg.version.trim() ? pkg.version.trim() : "dev";
|
||||||
|
} catch (_) {
|
||||||
|
return "dev";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFallbackCache(message, { configured } = { configured: false }) {
|
||||||
|
latestCommitCache = {
|
||||||
|
version: getPackageVersion(),
|
||||||
|
message: message || "Version info not available.",
|
||||||
|
author: "",
|
||||||
|
date: nowFormatted(),
|
||||||
|
configured: !!configured,
|
||||||
|
source: "fallback",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logThrottled(...args) {
|
||||||
|
const t = Date.now();
|
||||||
|
if (t - lastLogTime < LOG_THROTTLE_MS) return;
|
||||||
|
lastLogTime = t;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(...args);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLatestCommitFromGitHub() {
|
async function fetchLatestCommitFromGitHub() {
|
||||||
const githubOwner = process.env.GITHUB_OWNER;
|
const { owner, repo, configured } = getGitHubConfig();
|
||||||
const githubRepo = process.env.GITHUB_REPO;
|
if (!configured) {
|
||||||
|
// Never attempt GitHub calls if not configured.
|
||||||
|
setFallbackCache("Version endpoint is not configured.", { configured: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `https://api.github.com/repos/${githubOwner}/${githubRepo}/commits`;
|
const url = `https://api.github.com/repos/${owner}/${repo}/commits`;
|
||||||
const response = await axios.get(url, {
|
const response = await axios.get(url, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -23,40 +72,44 @@ async function fetchLatestCommitFromGitHub() {
|
|||||||
if (response.data && response.data.length > 0) {
|
if (response.data && response.data.length > 0) {
|
||||||
const latestCommit = response.data[0];
|
const latestCommit = response.data[0];
|
||||||
latestCommitCache = {
|
latestCommitCache = {
|
||||||
sha: latestCommit.sha,
|
version: String(latestCommit.sha || "").substring(0, 5),
|
||||||
message: latestCommit.commit.message,
|
message: latestCommit?.commit?.message || "",
|
||||||
author: latestCommit.commit.author.name,
|
author: latestCommit?.commit?.author?.name || "",
|
||||||
date: latestCommit.commit.author.date,
|
date: moment(latestCommit?.commit?.author?.date || Date.now())
|
||||||
|
.tz(TIMEZONE)
|
||||||
|
.format("YYYY-MM-DD HH:mm:ss"),
|
||||||
|
configured: true,
|
||||||
|
source: "github",
|
||||||
};
|
};
|
||||||
lastFetchedTime = Date.now();
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No commits found.");
|
throw new Error("No commits found.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching commit:", error.message);
|
// Keep serving a non-500 response, and don't spam logs.
|
||||||
|
logThrottled("Error fetching commit:", error?.message || error);
|
||||||
|
setFallbackCache("GitHub commit data is not available.", { configured: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial fetch to populate cache
|
// Initialize cache (without crashing / spamming when not configured).
|
||||||
fetchLatestCommitFromGitHub();
|
const initialCfg = getGitHubConfig();
|
||||||
|
if (!initialCfg.configured) {
|
||||||
// Refresh cache every 5 minutes
|
setFallbackCache("Version endpoint is not configured.", { configured: false });
|
||||||
setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
|
} else {
|
||||||
|
fetchLatestCommitFromGitHub();
|
||||||
|
const timer = setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
|
||||||
|
// Don't keep the process alive just for this polling.
|
||||||
|
if (typeof timer.unref === "function") timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
// Route to get the latest commit (cached)
|
// Route to get the latest commit (cached)
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
|
// Always return something (client clears footer on non-2xx).
|
||||||
if (!latestCommitCache) {
|
if (!latestCommitCache) {
|
||||||
return res.status(500).json({ message: "Commit data is not available yet." });
|
const cfg = getGitHubConfig();
|
||||||
|
setFallbackCache("Version info not available.", { configured: cfg.configured });
|
||||||
}
|
}
|
||||||
|
return res.status(200).json(latestCommitCache);
|
||||||
res.json({
|
|
||||||
version: latestCommitCache.sha.substring(0, 5),
|
|
||||||
message: latestCommitCache.message,
|
|
||||||
author: latestCommitCache.author,
|
|
||||||
date: moment(latestCommitCache.date)
|
|
||||||
.tz("America/New_York")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss"),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user