Put groups, password and photo inside the user editor

Opening the editor to fix a name and then having to close it to reach a password or a group
was three dialogs for one sitting; the row buttons stay for one-click access from the list.
This commit is contained in:
Gmer4Lfe
2026-08-15 11:54:03 -04:00
parent 81aeb0619b
commit 2697b46616
+214 -13
View File
@@ -108,6 +108,17 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
.vv-au-av.none { font-size:9px;color:#4a4a4a;text-align:center;line-height:22px;font-weight:bold;
letter-spacing:.02em; }
.vv-au-av.big { width:88px;height:88px;border-radius:5px;line-height:88px;font-size:26px; }
/* ── Editor sections ─────────────────────────────────────────────────────── */
/* Below the field block and its Save, so the split between "staged until Save details" and
"applies when you press its own button" is a visible line rather than a convention. */
.vv-au-sect { margin-top:6px;padding-top:10px;border-top:1px solid #222; }
.vv-au-sect-h { font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;
margin:10px 0 5px; }
.vv-au-sect-h:first-child { margin-top:0; }
/* Wider only where it has to be — the editor now carries a photo beside a form. The other dialogs
on this page are single columns and would just get airier. */
.vv-au-modal.wide { max-width:620px; }
.vv-au-user-email{ font-size:10px;color:#444;flex:1; }
.vv-au-user-acts { display:flex;gap:4px;margin-left:auto;flex-shrink:0; }
@@ -396,8 +407,12 @@ function _post(params, cb) {
.catch(e => cb({ ok: false, error: String(e) }));
}
function _modal(html) {
document.getElementById('vv-au-modal').innerHTML = html;
// wide is for the one dialog that carries a photo beside a form. Reset on every call rather than
// only set, or a narrow dialog opened after the editor would inherit its width.
function _modal(html, wide) {
const m = document.getElementById('vv-au-modal');
m.innerHTML = html;
m.classList.toggle('wide', !!wide);
document.getElementById('vv-au-overlay').classList.add('open');
}
function _closeModal() {
@@ -654,7 +669,7 @@ document.getElementById('vv-au-panel-proxies').addEventListener('click', async e
});
// ── Users ─────────────────────────────────────────────────────────────────────
function _loadUsers() {
function _loadUsers(done) {
const loading = document.getElementById('vv-au-users-loading');
const list = document.getElementById('vv-au-users-list');
const empty = document.getElementById('vv-au-users-empty');
@@ -664,9 +679,12 @@ function _loadUsers() {
_get('lldap_users', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
// done() fires on every path including failure and the empty list. An open editor waits on it
// to redraw its sections, and a refresh that silently never called back would leave the dialog
// showing the state from before the change with no indication anything had happened.
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; if (done) done(); return; }
_users = r.users || [];
if (!_users.length) { empty.style.display = 'block'; return; }
if (!_users.length) { empty.style.display = 'block'; if (done) done(); return; }
list.innerHTML = _users.map(u => {
const grpBadges = (u.groups||[]).map(g => `<span class="vv-au-badge grp" title="Click to remove" data-rm-from-group="${_esc(u.id)}" data-gid="${g.id}">${_esc(g.displayName)}</span>`).join('');
// Only fetched for the users who have one — has_avatar comes from the attribute names, so
@@ -693,6 +711,7 @@ function _loadUsers() {
</div>
</div>`;
}).join('');
if (done) done();
});
}
@@ -734,9 +753,45 @@ function _userModal(uid) {
</div>` : ''}
<div class="vv-au-err" id="um-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="um-cancel">Cancel</button>
<button class="vv-au-btn prim" id="um-save">${u ? 'Save' : 'Create'}</button>
</div>`);
<button class="vv-au-btn" id="um-cancel">${u ? 'Close' : 'Cancel'}</button>
<button class="vv-au-btn prim" id="um-save">${u ? 'Save details' : 'Create'}</button>
</div>
<!-- Everything else lldap can do to this user, in the place you already have open. The row
buttons stay for one-click access from the list; this is for when you are already in here
and the alternative was closing the dialog to reach a different one.
These sections apply on their own buttons rather than on Save, because each is a separate
lldap mutation with its own failure — batching them behind one button would mean reporting
"saved" for a password that took and a group that did not. Save details covers exactly the
fields above it, which is what its label says. -->
${u ? `
<div class="vv-au-sect" id="um-more">
<div class="vv-au-sect-h">Groups</div>
<div id="um-groups"></div>
<div class="vv-au-sect-h">Password</div>
<div style="display:flex;gap:6px;align-items:flex-start">
<input class="vv-au-input" id="um-pw" type="password" autocomplete="new-password"
placeholder="New password" style="flex:1">
<button class="vv-au-btn" id="um-pw-set">Set</button>
</div>
<div class="vv-au-hint" id="um-pw-msg"></div>
<div class="vv-au-sect-h">Photo</div>
<div style="display:flex;gap:10px;align-items:center">
<div id="um-ph-preview"></div>
<div style="flex:1;min-width:0">
<input class="vv-au-input" type="file" id="um-ph-file" accept="image/*">
<div class="vv-au-hint" id="um-ph-msg">Any image; cropped square, ${VV_AVATAR_PX}px, saved as JPEG.</div>
<div style="display:flex;gap:6px;margin-top:5px">
<button class="vv-au-btn prim" id="um-ph-set" disabled>Set photo</button>
<button class="vv-au-btn danger" id="um-ph-rm" ${u.has_avatar ? '' : 'disabled'}>Remove</button>
</div>
</div>
</div>
</div>` : ''}`, !!u);
if (u) _userModalExtras(u);
document.getElementById('um-cancel').onclick = _closeModal;
document.getElementById('um-save').onclick = () => {
@@ -753,8 +808,15 @@ function _userModal(uid) {
btn.disabled = true; btn.textContent = 'Saving…';
const done = r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save':'Create'; return; }
_closeModal(); _loadUsers();
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save details':'Create'; return; }
// A create is finished when it succeeds, so the dialog closes. An edit is not — the sections
// below are the reason it is worth staying in, and closing on the first Save would put the
// operator straight back to reopening it.
if (!u) { _closeModal(); _loadUsers(); return; }
_showModalErr('um-err', '');
btn.disabled = false; btn.textContent = 'Saved ✓';
setTimeout(() => { btn.textContent = 'Save details'; }, 2500);
_loadUsers();
};
// first_name/last_name are always sent from this form, including empty, because the form does
// offer them — an empty box here means "clear it", which the endpoint turns into a proper
@@ -764,6 +826,144 @@ function _userModal(uid) {
};
}
// ── The editor's live sections ────────────────────────────────────────────────
// Groups, password and photo, wired inside the open Edit User dialog. Each acts immediately and
// reports in place; none of them closes the dialog, because the reason they are here at all is
// that having to leave the editor to reach them was the complaint.
//
// The background lists are refreshed too, so the row behind the dialog is not left showing the
// groups or the photo the user had a moment ago.
function _userModalExtras(u) {
const uid = u.id;
// Re-read from _users after a refresh rather than trusting the copy captured when the dialog
// opened — a group added here changes the object the next redraw has to render from.
const cur = () => _users.find(x => x.id === uid) || u;
const drawGroups = () => {
const me = cur();
const mine = me.groups || [];
const gids = new Set(mine.map(g => g.id));
const free = _groups.filter(g => !gids.has(g.id));
document.getElementById('um-groups').innerHTML =
`<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">
${mine.length ? mine.map(g => `<span class="vv-au-badge grp" style="cursor:pointer"
data-um-rmgrp="${g.id}" title="Remove from ${_esc(g.displayName)}">${_esc(g.displayName)} ✕</span>`).join('')
: '<span class="vv-au-hint">No groups</span>'}
</div>
${free.length ? `<div style="display:flex;gap:6px">
<select class="vv-au-select" id="um-grp-pick" style="flex:1">
${free.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('')}
</select>
<button class="vv-au-btn" id="um-grp-add">Add</button>
</div>` : '<div class="vv-au-hint">In every group</div>'}`;
const add = document.getElementById('um-grp-add');
if (add) add.onclick = () => {
const gid = parseInt(document.getElementById('um-grp-pick').value);
add.disabled = true;
_post({ action:'lldap_add_to_group', uid, gid }, r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Could not add to group'); add.disabled = false; return; }
refresh(drawGroups);
});
};
document.querySelectorAll('[data-um-rmgrp]').forEach(el => el.onclick = async () => {
const gid = parseInt(el.dataset.umRmgrp);
const g = _groups.find(x => x.id === gid);
if (!await vvConfirm('Remove from group "' + (g?.displayName||gid) + '"?')) return;
_post({ action:'lldap_remove_from_group', uid, gid }, r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Could not remove from group'); return; }
refresh(drawGroups);
});
});
};
const drawPhoto = () => {
const me = cur();
document.getElementById('um-ph-preview').innerHTML = me.has_avatar
? `<img class="vv-au-av big" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(uid)}&v=${_avatarBust}" alt="">`
: `<span class="vv-au-av big none">${_esc(_initials(me))}</span>`;
const rm = document.getElementById('um-ph-rm');
if (rm) rm.disabled = !me.has_avatar;
};
// Both lists, because a group change moves membership on either side of it. The dialog stays
// open throughout — only the section that changed is redrawn.
const refresh = after => {
let left = 2;
const done = () => { if (--left === 0) after && after(); };
_loadUsers(done); _loadGroups(done);
};
drawGroups();
drawPhoto();
// ── Password ──
const pwBtn = document.getElementById('um-pw-set');
pwBtn.onclick = () => {
const inp = document.getElementById('um-pw'), msg = document.getElementById('um-pw-msg');
const pass = inp.value;
if (!pass) { msg.textContent = 'Enter a password first.'; msg.style.color = '#ef5350'; return; }
pwBtn.disabled = true; pwBtn.textContent = 'Setting…';
_post({ action:'lldap_set_password', uid, password: pass }, r => {
pwBtn.disabled = false; pwBtn.textContent = 'Set';
if (!r.ok) { msg.textContent = r.error || 'Failed'; msg.style.color = '#ef5350'; return; }
// Cleared on success so the new password is not left sitting in a field behind an open
// dialog, and so a second click cannot silently set it again.
inp.value = '';
msg.textContent = 'Password changed ✓'; msg.style.color = '#4caf50';
setTimeout(() => { msg.textContent = ''; msg.style.color = ''; }, 4000);
});
};
// ── Photo ──
let pending = null;
document.getElementById('um-ph-file').addEventListener('change', async e => {
const f = e.target.files && e.target.files[0];
const msg = document.getElementById('um-ph-msg');
if (!f) return;
try {
pending = await _toJpegBase64(f);
msg.textContent = `${f.name} → ${Math.round(pending.length * 3 / 4 / 1024)} KB JPEG`;
msg.style.color = '';
document.getElementById('um-ph-preview').innerHTML =
`<img class="vv-au-av big" src="data:image/jpeg;base64,${pending}" alt="">`;
document.getElementById('um-ph-set').disabled = false;
} catch (err) {
pending = null;
document.getElementById('um-ph-set').disabled = true;
msg.textContent = err.message || 'Could not read that image'; msg.style.color = '#ef5350';
}
});
document.getElementById('um-ph-set').onclick = () => {
if (!pending) return;
const btn = document.getElementById('um-ph-set'), msg = document.getElementById('um-ph-msg');
btn.disabled = true; btn.textContent = 'Saving…';
_post({ action:'lldap_set_avatar', uid, avatar: pending }, r => {
btn.textContent = 'Set photo';
if (!r.ok) { btn.disabled = false; msg.textContent = r.error||'Upload failed'; msg.style.color = '#ef5350'; return; }
pending = null;
document.getElementById('um-ph-file').value = '';
_avatarBust = Date.now();
msg.textContent = 'Photo saved ✓'; msg.style.color = '#4caf50';
refresh(drawPhoto);
});
};
document.getElementById('um-ph-rm').onclick = async () => {
if (!await vvConfirm('Remove this photo?')) return;
const btn = document.getElementById('um-ph-rm'), msg = document.getElementById('um-ph-msg');
btn.disabled = true;
_post({ action:'lldap_remove_avatar', uid }, r => {
if (!r.ok) { btn.disabled = false; msg.textContent = r.error||'Removal failed'; msg.style.color = '#ef5350'; return; }
_avatarBust = Date.now();
msg.textContent = 'Photo removed ✓'; msg.style.color = '#4caf50';
refresh(drawPhoto);
});
};
}
// ── Photo ─────────────────────────────────────────────────────────────────────
// Bumped after every upload or removal. The avatar endpoint is a plain GET the browser caches, so
// without a changing parameter the row would keep drawing the previous face after a change.
@@ -978,7 +1178,7 @@ document.getElementById('vv-au-panel-users').addEventListener('click', async e =
});
// ── Groups ────────────────────────────────────────────────────────────────────
function _loadGroups() {
function _loadGroups(done) {
const loading = document.getElementById('vv-au-groups-loading');
const list = document.getElementById('vv-au-groups-list');
const empty = document.getElementById('vv-au-groups-empty');
@@ -988,9 +1188,9 @@ function _loadGroups() {
_get('lldap_groups', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; if (done) done(); return; }
_groups = r.groups || [];
if (!_groups.length) { empty.style.display = 'block'; return; }
if (!_groups.length) { empty.style.display = 'block'; if (done) done(); return; }
list.innerHTML = _groups.map(g => {
const members = (g.users||[]).map(u =>
`<div class="vv-au-grp-member">
@@ -1010,6 +1210,7 @@ function _loadGroups() {
${members || '<div style="font-size:11px;color:#333;padding:2px 0">No members</div>'}
</div>`;
}).join('');
if (done) done();
});
}