536 lines
20 KiB
JavaScript
536 lines
20 KiB
JavaScript
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||
import { sendRequest } from './networkUtils.js';
|
||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||
|
||
let lastLoginData = null;
|
||
export function setLastLoginData(data) {
|
||
lastLoginData = data;
|
||
// expose to auth.js so it can tell form-login vs basic/oidc
|
||
//window.__lastLoginData = data;
|
||
}
|
||
|
||
export function openTOTPLoginModal() {
|
||
let totpLoginModal = document.getElementById("totpLoginModal");
|
||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||
const modalBg = isDarkMode ? "#2c2c2c" : "#fff";
|
||
const textColor = isDarkMode ? "#e0e0e0" : "#000";
|
||
|
||
if (!totpLoginModal) {
|
||
totpLoginModal = document.createElement("div");
|
||
totpLoginModal.id = "totpLoginModal";
|
||
totpLoginModal.style.cssText = `
|
||
position: fixed;
|
||
top: 0; left: 0;
|
||
width: 100vw; height: 100vh;
|
||
background-color: rgba(0,0,0,0.5);
|
||
display: flex; justify-content: center; align-items: center;
|
||
z-index: 3200;
|
||
`;
|
||
totpLoginModal.innerHTML = `
|
||
<div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};">
|
||
<span id="closeTOTPLoginModal" class="editor-close-btn">×</span>
|
||
<div id="totpSection">
|
||
<h3>${t("enter_totp_code")}</h3>
|
||
<input type="text" id="totpLoginInput" maxlength="6"
|
||
style="font-size:24px; text-align:center; width:100%; padding:10px;"
|
||
placeholder="6-digit code" />
|
||
</div>
|
||
<a href="#" id="toggleRecovery" style="display:block; margin-top:10px; font-size:14px;">${t("use_recovery_code_instead")}</a>
|
||
<div id="recoverySection" style="display:none; margin-top:10px;">
|
||
<h3>${t("enter_recovery_code")}</h3>
|
||
<input type="text" id="recoveryInput"
|
||
style="font-size:24px; text-align:center; width:100%; padding:10px;"
|
||
placeholder="Recovery code" />
|
||
<button type="button" id="submitRecovery" class="btn btn-secondary" style="margin-top:10px;">${t("submit_recovery_code")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(totpLoginModal);
|
||
|
||
// Close button
|
||
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
|
||
totpLoginModal.style.display = "none";
|
||
});
|
||
|
||
// Toggle between TOTP and Recovery
|
||
document.getElementById("toggleRecovery").addEventListener("click", function (e) {
|
||
e.preventDefault();
|
||
const totpSection = document.getElementById("totpSection");
|
||
const recoverySection = document.getElementById("recoverySection");
|
||
const toggleLink = this;
|
||
if (recoverySection.style.display === "none") {
|
||
totpSection.style.display = "none";
|
||
recoverySection.style.display = "block";
|
||
toggleLink.textContent = t("use_totp_code_instead");
|
||
} else {
|
||
recoverySection.style.display = "none";
|
||
totpSection.style.display = "block";
|
||
toggleLink.textContent = t("use_recovery_code_instead");
|
||
}
|
||
});
|
||
|
||
// Recovery submission
|
||
document.getElementById("submitRecovery").addEventListener("click", () => {
|
||
const recoveryCode = document.getElementById("recoveryInput").value.trim();
|
||
if (!recoveryCode) {
|
||
showToast(t("please_enter_recovery_code"));
|
||
return;
|
||
}
|
||
fetch("/api/totp_recover.php", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-Token": window.csrfToken
|
||
},
|
||
body: JSON.stringify({ recovery_code: recoveryCode })
|
||
})
|
||
.then(res => res.json())
|
||
.then(json => {
|
||
if (json.status === "ok") {
|
||
window.location.href = "/index.html";
|
||
} else {
|
||
showToast(json.message || t("recovery_code_verification_failed"));
|
||
}
|
||
})
|
||
.catch(() => {
|
||
showToast(t("error_verifying_recovery_code"));
|
||
});
|
||
});
|
||
|
||
// TOTP submission
|
||
const totpInput = document.getElementById("totpLoginInput");
|
||
totpInput.focus();
|
||
totpInput.addEventListener("input", async function () {
|
||
const code = this.value.trim();
|
||
if (code.length !== 6) return;
|
||
|
||
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||
if (!tokenRes.ok) {
|
||
showToast(t("totp_verification_failed"));
|
||
return;
|
||
}
|
||
window.csrfToken = (await tokenRes.json()).csrf_token;
|
||
|
||
const res = await fetch("/api/totp_verify.php", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-Token": window.csrfToken
|
||
},
|
||
body: JSON.stringify({ totp_code: code })
|
||
});
|
||
|
||
if (res.ok) {
|
||
const json = await res.json();
|
||
if (json.status === "ok") {
|
||
window.location.href = "/index.html";
|
||
return;
|
||
}
|
||
showToast(json.message || t("totp_verification_failed"));
|
||
} else {
|
||
showToast(t("totp_verification_failed"));
|
||
}
|
||
this.value = "";
|
||
totpLoginModal.style.display = "flex";
|
||
this.focus();
|
||
});
|
||
} else {
|
||
// Re-open existing modal
|
||
totpLoginModal.style.display = "flex";
|
||
const totpInput = document.getElementById("totpLoginInput");
|
||
totpInput.value = "";
|
||
totpInput.style.display = "block";
|
||
totpInput.focus();
|
||
document.getElementById("recoverySection").style.display = "none";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch current user info (username, profile_picture, totp_enabled)
|
||
*/
|
||
async function fetchCurrentUser() {
|
||
try {
|
||
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return await res.json();
|
||
} catch (e) {
|
||
console.warn('fetchCurrentUser failed:', e);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Normalize any profile‐picture URL:
|
||
* - strip leading colons
|
||
* - ensure exactly one leading slash
|
||
*/
|
||
function normalizePicUrl(raw) {
|
||
if (!raw) return '';
|
||
// take only what's after the last colon
|
||
const parts = raw.split(':');
|
||
let pic = parts[parts.length - 1];
|
||
// strip any stray colons
|
||
pic = pic.replace(/^:+/, '');
|
||
// ensure leading slash
|
||
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||
return pic;
|
||
}
|
||
|
||
export async function openUserPanel() {
|
||
// 1) load data
|
||
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
|
||
const raw = profile_picture;
|
||
const picUrl = normalizePicUrl(raw);
|
||
|
||
// 2) dark‐mode helpers
|
||
const isDark = document.body.classList.contains('dark-mode');
|
||
const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)';
|
||
const contentCss = `
|
||
background: ${isDark ? '#2c2c2c' : '#fff'};
|
||
color: ${isDark ? '#e0e0e0' : '#000'};
|
||
padding: 20px;
|
||
max-width: 600px;
|
||
width: 90%;
|
||
border-radius: 8px;
|
||
overflow-y: auto;
|
||
max-height: 415px;
|
||
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||
box-sizing: border-box;
|
||
|
||
/* hide scrollbar in Firefox */
|
||
scrollbar-width: none;
|
||
/* hide scrollbar in IE 10+ */
|
||
-ms-overflow-style: none;
|
||
`;
|
||
|
||
// 3) build or re-use modal
|
||
let modal = document.getElementById('userPanelModal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'userPanelModal';
|
||
modal.style.cssText = `
|
||
position:fixed; top:0; left:0; right:0; bottom:0;
|
||
background:${overlayBg};
|
||
display:flex; align-items:center; justify-content:center;
|
||
z-index:1000;
|
||
`;
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="${contentCss}">
|
||
<span id="closeUserPanel" class="editor-close-btn">×</span>
|
||
<div style="text-align:center; margin-bottom:20px;">
|
||
<div style="position:relative; width:80px; height:80px; margin:0 auto;">
|
||
<img id="profilePicPreview"
|
||
src="${picUrl || '/assets/default-avatar.png'}"
|
||
style="width:100%; height:100%; border-radius:50%; object-fit:cover;">
|
||
<label for="profilePicInput"
|
||
style="
|
||
position:absolute; bottom:0; right:0;
|
||
width:24px; height:24px; background:rgba(0,0,0,0.6);
|
||
border-radius:50%; display:flex; align-items:center;
|
||
justify-content:center; cursor:pointer;">
|
||
<i class="material-icons" style="color:#fff; font-size:16px;">edit</i>
|
||
</label>
|
||
<input type="file" id="profilePicInput" accept="image/*" style="display:none">
|
||
</div>
|
||
</div>
|
||
<h3 style="text-align:center; margin-bottom:20px;">
|
||
${t('user_panel')} (${username})
|
||
</h3>
|
||
<button id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom:15px;">
|
||
${t('change_password')}
|
||
</button>
|
||
<fieldset style="margin-bottom:15px;">
|
||
<legend>${t('totp_settings')}</legend>
|
||
<label style="cursor:pointer;">
|
||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align:middle;">
|
||
${t('enable_totp')}
|
||
</label>
|
||
</fieldset>
|
||
<fieldset style="margin-bottom:15px;">
|
||
<legend>${t('language')}</legend>
|
||
<select id="languageSelector" class="form-select">
|
||
<option value="en">${t('english')}</option>
|
||
<option value="es">${t('spanish')}</option>
|
||
<option value="fr">${t('french')}</option>
|
||
<option value="de">${t('german')}</option>
|
||
</select>
|
||
</fieldset>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
// --- wire up handlers ---
|
||
|
||
modal.querySelector('#closeUserPanel')
|
||
.addEventListener('click', () => modal.style.display = 'none');
|
||
|
||
modal.querySelector('#openChangePasswordModalBtn')
|
||
.addEventListener('click', () => {
|
||
document.getElementById('changePasswordModal').style.display = 'block';
|
||
});
|
||
|
||
// TOTP
|
||
const totpCb = modal.querySelector('#userTOTPEnabled');
|
||
totpCb.addEventListener('change', async function () {
|
||
const resp = await fetch('/api/updateUserPanel.php', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': window.csrfToken
|
||
},
|
||
body: JSON.stringify({ totp_enabled: this.checked })
|
||
});
|
||
const js = await resp.json();
|
||
if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
|
||
else if (this.checked) openTOTPModal();
|
||
});
|
||
|
||
// Language
|
||
const langSel = modal.querySelector('#languageSelector');
|
||
langSel.addEventListener('change', function () {
|
||
localStorage.setItem('language', this.value);
|
||
setLocale(this.value);
|
||
applyTranslations();
|
||
});
|
||
|
||
// Auto‐upload on file select
|
||
const fileInput = modal.querySelector('#profilePicInput');
|
||
fileInput.addEventListener('change', async function () {
|
||
const file = this.files[0];
|
||
if (!file) return;
|
||
|
||
// preview immediately
|
||
const img = modal.querySelector('#profilePicPreview');
|
||
img.src = URL.createObjectURL(file);
|
||
|
||
// upload
|
||
const fd = new FormData();
|
||
fd.append('profile_picture', file);
|
||
try {
|
||
const res = await fetch('/api/profile/uploadPicture.php', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'X-CSRF-Token': window.csrfToken },
|
||
body: fd
|
||
});
|
||
const text = await res.text();
|
||
const js = JSON.parse(text || '{}');
|
||
if (!res.ok) {
|
||
showToast(js.error || t('error_updating_picture'));
|
||
return;
|
||
}
|
||
const newUrl = normalizePicUrl(js.url);
|
||
img.src = newUrl;
|
||
localStorage.setItem('profilePicUrl', newUrl);
|
||
// refresh the header immediately
|
||
updateAuthenticatedUI(window.__lastAuthData || {});
|
||
showToast(t('profile_picture_updated'));
|
||
} catch (e) {
|
||
console.error(e);
|
||
showToast(t('error_updating_picture'));
|
||
}
|
||
});
|
||
|
||
} else {
|
||
|
||
modal.style.background = overlayBg;
|
||
const contentEl = modal.querySelector('.modal-content');
|
||
contentEl.style.cssText = contentCss;
|
||
// re-open: sync current values
|
||
modal.querySelector('#profilePicPreview').src = picUrl || '/images/default-avatar.png';
|
||
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
||
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
||
}
|
||
|
||
// show
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
function showRecoveryCodeModal(recoveryCode) {
|
||
const recoveryModal = document.createElement("div");
|
||
recoveryModal.id = "recoveryModal";
|
||
recoveryModal.style.cssText = `
|
||
position: fixed;
|
||
top: 0; left: 0;
|
||
width: 100vw; height: 100vh;
|
||
background-color: rgba(0,0,0,0.3);
|
||
display: flex; justify-content: center; align-items: center;
|
||
z-index: 3200;
|
||
`;
|
||
recoveryModal.innerHTML = `
|
||
<div style="background:#fff; color:#000; padding:20px; max-width:400px; width:90%; border-radius:8px; text-align:center;">
|
||
<h3>${t("your_recovery_code")}</h3>
|
||
<p>${t("please_save_recovery_code")}</p>
|
||
<code style="display:block; margin:10px 0; font-size:20px;">${recoveryCode}</code>
|
||
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(recoveryModal);
|
||
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
||
recoveryModal.remove();
|
||
});
|
||
}
|
||
|
||
export function openTOTPModal() {
|
||
let totpModal = document.getElementById("totpModal");
|
||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||
const modalContentStyles = `
|
||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||
padding: 20px; max-width:400px; width:90%; border-radius:8px; position:relative;
|
||
`;
|
||
if (!totpModal) {
|
||
totpModal = document.createElement("div");
|
||
totpModal.id = "totpModal";
|
||
totpModal.style.cssText = `
|
||
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||
background-color:${overlayBackground}; display:flex; justify-content:center; align-items:center;
|
||
z-index:3100;
|
||
`;
|
||
totpModal.innerHTML = `
|
||
<div class="modal-content" style="${modalContentStyles}">
|
||
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
||
<h3>${t("totp_setup")}</h3>
|
||
<p>${t("scan_qr_code")}</p>
|
||
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width:100%; height:auto; display:block; margin:0 auto;" />
|
||
<br/>
|
||
<p>${t("enter_totp_confirmation")}</p>
|
||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||
<br/><br/>
|
||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(totpModal);
|
||
loadTOTPQRCode();
|
||
document.getElementById("closeTOTPModal").addEventListener("click", () => closeTOTPModal(true));
|
||
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
||
const code = document.getElementById("totpConfirmInput").value.trim();
|
||
if (code.length !== 6) { showToast(t("please_enter_valid_code")); return; }
|
||
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||
if (!tokenRes.ok) { showToast(t("error_verifying_totp_code")); return; }
|
||
window.csrfToken = (await tokenRes.json()).csrf_token;
|
||
const verifyRes = await fetch("/api/totp_verify.php", {
|
||
method: "POST", credentials: "include",
|
||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||
body: JSON.stringify({ totp_code: code })
|
||
});
|
||
if (!verifyRes.ok) { showToast(t("totp_verification_failed")); return; }
|
||
const result = await verifyRes.json();
|
||
if (result.status !== "ok") { showToast(result.message || t("totp_verification_failed")); return; }
|
||
showToast(t("totp_enabled_successfully"));
|
||
const saveRes = await fetch("/api/totp_saveCode.php", {
|
||
method: "POST", credentials: "include", headers: { "X-CSRF-Token": window.csrfToken }
|
||
});
|
||
if (!saveRes.ok) { showToast(t("error_generating_recovery_code")); closeTOTPModal(false); return; }
|
||
const data = await saveRes.json();
|
||
if (data.status === "ok" && data.recoveryCode) showRecoveryCodeModal(data.recoveryCode);
|
||
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
||
closeTOTPModal(false);
|
||
});
|
||
|
||
// Focus the input and attach enter key listener
|
||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||
if (totpConfirmInput) {
|
||
setTimeout(() => {
|
||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||
if (totpConfirmInput) totpConfirmInput.focus();
|
||
}, 100);
|
||
}
|
||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||
} else {
|
||
totpModal.style.display = "flex";
|
||
totpModal.style.backgroundColor = overlayBackground;
|
||
const modalContent = totpModal.querySelector(".modal-content");
|
||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||
loadTOTPQRCode();
|
||
const totpInput = document.getElementById("totpConfirmInput");
|
||
if (totpInput) {
|
||
totpInput.value = "";
|
||
setTimeout(() => totpInput.focus(), 100);
|
||
}
|
||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||
}
|
||
}
|
||
|
||
function loadTOTPQRCode() {
|
||
fetch("/api/totp_setup.php", {
|
||
method: "GET",
|
||
credentials: "include",
|
||
headers: { "X-CSRF-Token": window.csrfToken }
|
||
})
|
||
.then(res => {
|
||
if (!res.ok) throw new Error("Failed to fetch QR code: " + res.status);
|
||
return res.blob();
|
||
})
|
||
.then(blob => {
|
||
const url = URL.createObjectURL(blob);
|
||
document.getElementById("totpQRCodeImage").src = url;
|
||
})
|
||
.catch(err => {
|
||
console.error(err);
|
||
showToast(t("error_loading_qr_code"));
|
||
});
|
||
}
|
||
|
||
export function closeTOTPModal(disable = true) {
|
||
const totpModal = document.getElementById("totpModal");
|
||
if (totpModal) totpModal.style.display = "none";
|
||
if (disable) {
|
||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||
if (totpCheckbox) {
|
||
totpCheckbox.checked = false;
|
||
localStorage.setItem("userTOTPEnabled", "false");
|
||
}
|
||
fetch("/api/totp_disable.php", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-Token": window.csrfToken
|
||
}
|
||
})
|
||
.then(r => r.json())
|
||
.then(result => {
|
||
if (!result.success) showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
||
})
|
||
.catch(() => showToast(t("error_disabling_totp_setting")));
|
||
}
|
||
}
|
||
|
||
export function openApiModal() {
|
||
let apiModal = document.getElementById("apiModal");
|
||
if (!apiModal) {
|
||
// create the container exactly as you do now inside openUserPanel
|
||
apiModal = document.createElement("div");
|
||
apiModal.id = "apiModal";
|
||
apiModal.style.cssText = `
|
||
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||
align-items: center; justify-content: center;
|
||
`;
|
||
apiModal.innerHTML = `
|
||
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(apiModal);
|
||
|
||
// wire up its close button
|
||
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||
apiModal.style.display = "none";
|
||
});
|
||
}
|
||
// finally, show it
|
||
apiModal.style.display = "flex";
|
||
} |