From 2697b466162f0177606f5293019415317cbc9df8 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sat, 15 Aug 2026 11:54:03 -0400 Subject: [PATCH] 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. --- Plugin/unraid/pages/auth.php | 227 +++++++++++++++++++++++++++++++++-- 1 file changed, 214 insertions(+), 13 deletions(-) diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php index e653f86..a2dd468 100644 --- a/Plugin/unraid/pages/auth.php +++ b/Plugin/unraid/pages/auth.php @@ -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 = ''+_esc(r.error)+''; 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 = ''+_esc(r.error)+''; 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 => `${_esc(g.displayName)}`).join(''); // Only fetched for the users who have one — has_avatar comes from the attribute names, so @@ -693,6 +711,7 @@ function _loadUsers() { `; }).join(''); + if (done) done(); }); } @@ -734,9 +753,45 @@ function _userModal(uid) { ` : ''}
- - -
`); + + + + + ${u ? ` +
+
Groups
+
+ +
Password
+
+ + +
+
+ +
Photo
+
+
+
+ +
Any image; cropped square, ${VV_AVATAR_PX}px, saved as JPEG.
+
+ + +
+
+
+
` : ''}`, !!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 = + `
+ ${mine.length ? mine.map(g => `${_esc(g.displayName)} ✕`).join('') + : 'No groups'} +
+ ${free.length ? `
+ + +
` : '
In every group
'}`; + + 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 + ? `` + : `${_esc(_initials(me))}`; + 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 = + ``; + 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 = ''+_esc(r.error)+''; loading.style.display='block'; return; } + if (!r.ok) { loading.innerHTML = ''+_esc(r.error)+''; 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 => `
@@ -1010,6 +1210,7 @@ function _loadGroups() { ${members || '
No members
'}
`; }).join(''); + if (done) done(); }); }