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:
+214
-13
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user