Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked

This commit is contained in:
Ryan
2025-05-14 06:51:16 -04:00
committed by GitHub
parent 939aa032f0
commit 87368143b5
5 changed files with 210 additions and 125 deletions

View File

@@ -77,6 +77,28 @@
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
**openUserPanel**
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
**updateAuthenticatedUI**
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
- **Dropdown rebuild logic** tweaked to update the toggles innerHTML with both avatar and username on every call.
**UserModel::getUser**
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
---
## Changes 5/8/2025

View File

@@ -1,13 +1,13 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error'=>'Unauthorized']);
exit;
http_response_code(401);
echo json_encode(['error'=>'Unauthorized']);
exit;
}
$user = $_SESSION['username'];

View File

@@ -223,6 +223,9 @@ async function fetchProfilePicture() {
}
export async function updateAuthenticatedUI(data) {
// Save latest auth data for later reuse
window.__lastAuthData = data;
// 1) Remove loading overlay safely
const loading = document.getElementById('loadingOverlay');
if (loading) loading.remove();
@@ -304,6 +307,11 @@ export async function updateAuthenticatedUI(data) {
? `<img src="${profilePicUrl}" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;">`
: `<i class="material-icons">account_circle</i>`;
// fallback username if missing
const usernameText = data.username
|| localStorage.getItem("username")
|| "";
if (!dd) {
dd = document.createElement("div");
dd.id = "userDropdown";
@@ -314,7 +322,11 @@ export async function updateAuthenticatedUI(data) {
toggle.id = "userDropdownToggle";
toggle.classList.add("btn","btn-user");
toggle.setAttribute("title", t("user_settings"));
toggle.innerHTML = `${avatarHTML}<span class="dropdown-username">${data.username}</span><span class="dropdown-caret"></span>`;
toggle.innerHTML = `
${avatarHTML}
<span class="dropdown-username">${usernameText}</span>
<span class="dropdown-caret"></span>
`;
dd.append(toggle);
// menu
@@ -375,9 +387,13 @@ export async function updateAuthenticatedUI(data) {
});
} else {
// update avatar only
// update avatar & username only
const tog = dd.querySelector("#userDropdownToggle");
tog.innerHTML = `${avatarHTML}<span class="dropdown-username">${data.username}</span><span class="dropdown-caret"></span>`;
tog.innerHTML = `
${avatarHTML}
<span class="dropdown-username">${usernameText}</span>
<span class="dropdown-caret"></span>
`;
dd.style.display = "inline-block";
}
}

View File

@@ -184,105 +184,128 @@ function normalizePicUrl(raw) {
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);
const raw = profile_picture;
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
// 2) darkmode helpers
const isDark = document.body.classList.contains('dark-mode');
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;
const contentStyle = `
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;
scrollbar-width: none;
-ms-overflow-style: none;
`;
/* hide scrollbar in Firefox */
scrollbar-width: none;
/* hide scrollbar in IE 10+ */
-ms-overflow-style: none;
`;
// 3) build or re-use modal
// 3) create or reuse modal
let modal = document.getElementById('userPanelModal');
if (!modal) {
// overlay
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;
Object.assign(modal.style, {
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
background: overlayBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '1000',
});
// content container
const content = document.createElement('div');
content.className = 'modal-content';
content.style.cssText = contentStyle;
// close button
const closeBtn = document.createElement('span');
closeBtn.id = 'closeUserPanel';
closeBtn.className = 'editor-close-btn';
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => modal.style.display = 'none');
content.appendChild(closeBtn);
// avatar + picker
const avatarWrapper = document.createElement('div');
avatarWrapper.style.cssText = 'text-align:center; margin-bottom:20px;';
const avatarInner = document.createElement('div');
avatarInner.style.cssText = 'position:relative; width:80px; height:80px; margin:0 auto;';
const img = document.createElement('img');
img.id = 'profilePicPreview';
img.src = picUrl;
img.alt = 'Profile Picture';
img.style.cssText = 'width:100%; height:100%; border-radius:50%; object-fit:cover;';
avatarInner.appendChild(img);
const label = document.createElement('label');
label.htmlFor = 'profilePicInput';
label.style.cssText = `
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;
`;
const editIcon = document.createElement('i');
editIcon.className = 'material-icons';
editIcon.style.cssText = 'color:#fff; font-size:16px;';
editIcon.textContent = 'edit';
label.appendChild(editIcon);
avatarInner.appendChild(label);
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'profilePicInput';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
avatarInner.appendChild(fileInput);
avatarWrapper.appendChild(avatarInner);
content.appendChild(avatarWrapper);
modal.innerHTML = `
<div class="modal-content" style="${contentCss}">
<span id="closeUserPanel" class="editor-close-btn">&times;</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);
// title
const title = document.createElement('h3');
title.style.cssText = 'text-align:center; margin-bottom:20px;';
title.textContent = `${t('user_panel')} (${username})`;
content.appendChild(title);
// --- wire up handlers ---
// change password btn
const pwdBtn = document.createElement('button');
pwdBtn.id = 'openChangePasswordModalBtn';
pwdBtn.className = 'btn btn-primary';
pwdBtn.style.marginBottom = '15px';
pwdBtn.textContent = t('change_password');
pwdBtn.addEventListener('click', () => {
document.getElementById('changePasswordModal').style.display = 'block';
});
content.appendChild(pwdBtn);
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 () {
// TOTP fieldset
const totpFs = document.createElement('fieldset');
totpFs.style.marginBottom = '15px';
const totpLegend = document.createElement('legend');
totpLegend.textContent = t('totp_settings');
totpFs.appendChild(totpLegend);
const totpLabel = document.createElement('label');
totpLabel.style.cursor = 'pointer';
const totpCb = document.createElement('input');
totpCb.type = 'checkbox';
totpCb.id = 'userTOTPEnabled';
totpCb.style.verticalAlign = 'middle';
totpCb.checked = totp_enabled;
totpCb.addEventListener('change', async function() {
const resp = await fetch('/api/updateUserPanel.php', {
method: 'POST',
credentials: 'include',
method: 'POST', credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Content-Type':'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({ totp_enabled: this.checked })
@@ -291,37 +314,52 @@ export async function openUserPanel() {
if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
else if (this.checked) openTOTPModal();
});
totpLabel.appendChild(totpCb);
totpLabel.append(` ${t('enable_totp')}`);
totpFs.appendChild(totpLabel);
content.appendChild(totpFs);
// Language
const langSel = modal.querySelector('#languageSelector');
langSel.addEventListener('change', function () {
// language fieldset
const langFs = document.createElement('fieldset');
langFs.style.marginBottom = '15px';
const langLegend = document.createElement('legend');
langLegend.textContent = t('language');
langFs.appendChild(langLegend);
const langSel = document.createElement('select');
langSel.id = 'languageSelector';
langSel.className = 'form-select';
['en','es','fr','de'].forEach(code => {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = t(code === 'en'? 'english' : code === 'es'? 'spanish' : code === 'fr'? 'french' : 'german');
langSel.appendChild(opt);
});
langSel.value = localStorage.getItem('language') || 'en';
langSel.addEventListener('change', function() {
localStorage.setItem('language', this.value);
setLocale(this.value);
applyTranslations();
});
langFs.appendChild(langSel);
content.appendChild(langFs);
// Autoupload on file select
const fileInput = modal.querySelector('#profilePicInput');
fileInput.addEventListener('change', async function () {
const file = this.files[0];
if (!file) return;
// wire up imageinput change
fileInput.addEventListener('change', async function() {
const f = this.files[0];
if (!f) return;
// preview immediately
const img = modal.querySelector('#profilePicPreview');
img.src = URL.createObjectURL(file);
img.src = URL.createObjectURL(f);
// upload
const fd = new FormData();
fd.append('profile_picture', file);
fd.append('profile_picture', f);
try {
const res = await fetch('/api/profile/uploadPicture.php', {
method: 'POST',
credentials: 'include',
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 || '{}');
const js = JSON.parse(text || '{}');
if (!res.ok) {
showToast(js.error || t('error_updating_picture'));
return;
@@ -329,7 +367,6 @@ export async function openUserPanel() {
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) {
@@ -338,15 +375,18 @@ export async function openUserPanel() {
}
});
// finalize
modal.appendChild(content);
document.body.appendChild(modal);
} 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';
// reuse on reopen
Object.assign(modal.style, { background: overlayBg });
const content = modal.querySelector('.modal-content');
content.style.cssText = contentStyle;
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
}
// show

View File

@@ -678,16 +678,23 @@ class userModel
}
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
// explode into at most 4 parts: [0]=username, [1]=hash, [2]=isAdmin, [3]=profileUrl (might include a trailing colon)
$parts = explode(':', $line, 4);
// split *all* the fields
$parts = explode(':', $line);
if ($parts[0] !== $username) {
continue;
}
// strip any trailing colon(s) from the URL field
$pic = isset($parts[3]) ? rtrim($parts[3], ':') : '';
// determine admin & totp
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
$totpEnabled = !empty($parts[3]);
// profile_picture is the 5th field if present
$pic = isset($parts[4]) ? $parts[4] : '';
return [
'username' => $parts[0],
'isAdmin' => $isAdmin,
'totp_enabled' => $totpEnabled,
'profile_picture' => $pic,
];
}