Reach the rest of what lldap exposes — photos, real names, group rename
lldap lets you edit five things about a user and one about a group; the page reached two of them, so correcting a surname or a group's name still meant opening the container's own WebUI.
This commit is contained in:
@@ -95,6 +95,25 @@ require_once dirname(__DIR__) . '/include/auth.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
// The one route here that does not answer in JSON — it streams the stored JPEG so the page can
|
||||
// point an <img> at it, rather than carrying 470 KB of base64 through the user list on every
|
||||
// load. Handled before the match so the Content-Type set above is replaced rather than sent
|
||||
// alongside image bytes.
|
||||
if ($action === 'lldap_avatar') {
|
||||
$raw = vv_lldap_avatar((string) ($_GET['uid'] ?? ''));
|
||||
if ($raw === '') { header('Content-Type: application/json'); http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'No avatar']); exit; }
|
||||
header('Content-Type: image/jpeg');
|
||||
header('Content-Length: ' . strlen($raw));
|
||||
// Private, because this is a photograph of a person behind an authenticated admin page,
|
||||
// and must not be held by anything between here and the browser. Short, because the
|
||||
// operator changing an avatar expects to see it change.
|
||||
header('Cache-Control: private, max-age=60');
|
||||
echo $raw;
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = match ($action) {
|
||||
'npm_proxies' => vv_npm_list_proxies(),
|
||||
'npm_certs' => ['ok' => true, 'certs' => vv_npm_list_certs()],
|
||||
@@ -122,8 +141,16 @@ $result = match ($action) {
|
||||
'npm_delete' => vv_npm_delete_proxy((int)($_POST['id'] ?? 0)),
|
||||
'npm_toggle' => vv_npm_toggle_proxy((int)($_POST['id'] ?? 0), ($_POST['enabled'] ?? '0') === '1'),
|
||||
// lldap
|
||||
'lldap_create_user' => vv_lldap_create_user($_POST['uid'] ?? '', $_POST['email'] ?? '', $_POST['display_name'] ?? '', $_POST['password'] ?? ''),
|
||||
'lldap_update_user' => vv_lldap_update_user($_POST['uid'] ?? '', $_POST['email'] ?? '', $_POST['display_name'] ?? ''),
|
||||
'lldap_create_user' => vv_lldap_create_user($_POST['uid'] ?? '', $_POST['email'] ?? '', $_POST['display_name'] ?? '', $_POST['password'] ?? '',
|
||||
$_POST['first_name'] ?? '', $_POST['last_name'] ?? ''),
|
||||
// isset, not ??'' — the update helper reads null as "not offered" and '' as "cleared", and
|
||||
// collapsing the two here would erase a first name every time a form omitted the field.
|
||||
'lldap_update_user' => vv_lldap_update_user($_POST['uid'] ?? '', $_POST['email'] ?? '', $_POST['display_name'] ?? '',
|
||||
isset($_POST['first_name']) ? (string) $_POST['first_name'] : null,
|
||||
isset($_POST['last_name']) ? (string) $_POST['last_name'] : null),
|
||||
'lldap_set_avatar' => vv_lldap_set_avatar($_POST['uid'] ?? '', $_POST['avatar'] ?? ''),
|
||||
'lldap_remove_avatar' => vv_lldap_remove_avatar($_POST['uid'] ?? ''),
|
||||
'lldap_rename_group' => vv_lldap_rename_group((int)($_POST['id'] ?? 0), $_POST['name'] ?? ''),
|
||||
'lldap_delete_user' => vv_lldap_delete_user($_POST['uid'] ?? ''),
|
||||
'lldap_set_password' => vv_lldap_set_password($_POST['uid'] ?? '', $_POST['password'] ?? ''),
|
||||
'lldap_create_group' => vv_lldap_create_group($_POST['name'] ?? ''),
|
||||
|
||||
@@ -283,9 +283,76 @@ function vv_lldap_gql(string $query, array $variables = []): array {
|
||||
}
|
||||
|
||||
function vv_lldap_list_users(): array {
|
||||
$r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }');
|
||||
// firstName/lastName/uuid were never requested, so the page could not show or edit them —
|
||||
// 31 of the 33 users here have them set and none of it was reachable without opening lldap's
|
||||
// own WebUI.
|
||||
//
|
||||
// The avatar itself is deliberately NOT in this query. It is a base64 JPEG stored inline, and
|
||||
// the six that exist here come to 470 KB — a third of a megabyte added to every load of the
|
||||
// tab, re-fetched on every refresh, to draw six thumbnails. The attribute *names* are enough
|
||||
// to know who has one, and the bytes are fetched per user by vv_lldap_avatar() through an
|
||||
// endpoint the browser can cache like any other image.
|
||||
$r = vv_lldap_gql('query { users { id displayName email firstName lastName uuid creationDate '
|
||||
. 'groups { id displayName } attributes { name } } }');
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
|
||||
return ['ok' => true, 'users' => $r['data']['users'] ?? []];
|
||||
$users = $r['data']['users'] ?? [];
|
||||
foreach ($users as &$u) {
|
||||
$names = array_column($u['attributes'] ?? [], 'name');
|
||||
$u['has_avatar'] = in_array('avatar', $names, true);
|
||||
// Sent to the browser as a flag, not a list. Nothing on the page reads the attribute names
|
||||
// and shipping 33 copies of the same nine strings is pure weight.
|
||||
unset($u['attributes']);
|
||||
}
|
||||
unset($u);
|
||||
return ['ok' => true, 'users' => $users];
|
||||
}
|
||||
|
||||
// Raw JPEG bytes for one user, or '' when they have no avatar. Returned as bytes rather than
|
||||
// base64 because the only caller streams it to an <img>, and re-encoding it to hand the browser
|
||||
// something it would immediately decode again is a third of a megabyte of nothing.
|
||||
function vv_lldap_avatar(string $userId): string {
|
||||
$r = vv_lldap_gql('query Avatar($id: String!) { user(userId: $id) { avatar } }', ['id' => $userId]);
|
||||
if (isset($r['errors'])) return '';
|
||||
$b64 = $r['data']['user']['avatar'] ?? '';
|
||||
if (!is_string($b64) || $b64 === '') return '';
|
||||
$raw = base64_decode($b64, true);
|
||||
return ($raw !== false && vv_lldap_is_jpeg($raw)) ? $raw : '';
|
||||
}
|
||||
|
||||
// lldap types this attribute JPEG_PHOTO and rejects anything else, so the check happens here where
|
||||
// the answer can name the problem. A rejection from the server arrives as a generic GraphQL error
|
||||
// several layers from the file the operator picked.
|
||||
function vv_lldap_is_jpeg(string $raw): bool {
|
||||
return strlen($raw) > 3 && substr($raw, 0, 3) === "\xFF\xD8\xFF";
|
||||
}
|
||||
|
||||
// One megabyte of JPEG, decoded. The browser resizes before upload so nothing near this should
|
||||
// arrive; the cap is here because this value is stored inline in the directory and read back on
|
||||
// every user query, and an unbounded one would be paid for on every page load forever.
|
||||
const VV_LLDAP_AVATAR_MAX = 1048576;
|
||||
|
||||
function vv_lldap_set_avatar(string $userId, string $b64): array {
|
||||
$b64 = preg_replace('#^data:image/[a-z+]+;base64,#i', '', trim($b64));
|
||||
$raw = base64_decode($b64, true);
|
||||
if ($raw === false || $raw === '') return ['ok' => false, 'error' => 'Image data could not be decoded'];
|
||||
if (!vv_lldap_is_jpeg($raw)) return ['ok' => false, 'error' => 'lldap stores avatars as JPEG only — that file is not one'];
|
||||
if (strlen($raw) > VV_LLDAP_AVATAR_MAX)
|
||||
return ['ok' => false, 'error' => 'Image is ' . round(strlen($raw) / 1024) . ' KB; the limit is '
|
||||
. round(VV_LLDAP_AVATAR_MAX / 1024) . ' KB'];
|
||||
|
||||
$r = vv_lldap_gql('mutation SetAvatar($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
|
||||
['user' => ['id' => $userId, 'avatar' => base64_encode($raw)]]);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Avatar update failed'];
|
||||
return ['ok' => true, 'bytes' => strlen($raw)];
|
||||
}
|
||||
|
||||
// Cleared through removeAttributes rather than by setting avatar to an empty string: lldap treats
|
||||
// an empty avatar as a value to validate, and it is not a JPEG.
|
||||
function vv_lldap_remove_avatar(string $userId): array {
|
||||
$r = vv_lldap_gql('mutation ClearAvatar($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
|
||||
['user' => ['id' => $userId, 'removeAttributes' => ['avatar']]]);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Avatar removal failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_list_groups(): array {
|
||||
@@ -294,20 +361,42 @@ function vv_lldap_list_groups(): array {
|
||||
return ['ok' => true, 'groups' => $r['data']['groups'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
|
||||
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password,
|
||||
string $firstName = '', string $lastName = ''): array {
|
||||
$user = ['id' => $id, 'email' => $email, 'displayName' => $displayName];
|
||||
// Omitted when blank rather than sent as "". lldap distinguishes the two, and an empty string
|
||||
// creates the attribute holding nothing, which then shows as set everywhere that tests for it.
|
||||
if ($firstName !== '') $user['firstName'] = $firstName;
|
||||
if ($lastName !== '') $user['lastName'] = $lastName;
|
||||
$r = vv_lldap_gql(
|
||||
'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
|
||||
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
|
||||
['user' => $user]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
|
||||
if ($password) vv_lldap_set_password($id, $password);
|
||||
return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
|
||||
}
|
||||
|
||||
function vv_lldap_update_user(string $id, string $email, string $displayName): array {
|
||||
// $firstName/$lastName are nullable on purpose: null means "the form did not offer this field, so
|
||||
// leave it alone", '' means "the operator cleared it". Passing '' for an absent field would erase
|
||||
// a name that 31 of the 33 users here have set.
|
||||
function vv_lldap_update_user(string $id, string $email, string $displayName,
|
||||
?string $firstName = null, ?string $lastName = null): array {
|
||||
$user = ['id' => $id, 'email' => $email, 'displayName' => $displayName];
|
||||
$remove = [];
|
||||
foreach (['firstName' => $firstName, 'lastName' => $lastName] as $k => $v) {
|
||||
if ($v === null) continue;
|
||||
if ($v === '') $remove[] = $k === 'firstName' ? 'first_name' : 'last_name';
|
||||
else $user[$k] = $v;
|
||||
}
|
||||
// Clearing goes through removeAttributes — setting the field to "" leaves the attribute in
|
||||
// place holding an empty string, which is a different thing to lldap and to anything reading
|
||||
// the directory over LDAP.
|
||||
if ($remove) $user['removeAttributes'] = $remove;
|
||||
|
||||
$r = vv_lldap_gql(
|
||||
'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
|
||||
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
|
||||
['user' => $user]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
|
||||
return ['ok' => true];
|
||||
@@ -353,6 +442,19 @@ function vv_lldap_create_group(string $name): array {
|
||||
return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
|
||||
}
|
||||
|
||||
// The only editable field a group has. Without it the sole way to correct a group's name was to
|
||||
// delete it and make a new one — which drops every member, and on this directory those group names
|
||||
// are what the Authelia rules match on, so the rule would keep naming a group that no longer
|
||||
// exists and quietly stop admitting anyone.
|
||||
function vv_lldap_rename_group(int $id, string $displayName): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation UpdateGroup($group: UpdateGroupInput!) { updateGroup(group: $group) { ok } }',
|
||||
['group' => ['id' => $id, 'displayName' => $displayName]]
|
||||
);
|
||||
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Rename failed'];
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function vv_lldap_delete_group(int $id): array {
|
||||
$r = vv_lldap_gql(
|
||||
'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',
|
||||
|
||||
@@ -96,7 +96,18 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||||
.vv-au-user-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
|
||||
.vv-au-user-row:last-child { border-bottom:none; }
|
||||
.vv-au-user-row:hover { background:#141414; }
|
||||
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px; }
|
||||
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px;display:flex;flex-direction:column;gap:1px; }
|
||||
/* The real name under the display name, only when the two differ. */
|
||||
.vv-au-user-real { font-size:9px;color:#4a4a4a;font-weight:normal; }
|
||||
|
||||
/* ── Avatars ─────────────────────────────────────────────────────────────── */
|
||||
/* Fixed square with a flex-shrink guard: the rows are a flex layout and an image left to its own
|
||||
intrinsic size drags every name on the page out of alignment while it loads. */
|
||||
.vv-au-av { width:22px;height:22px;border-radius:3px;object-fit:cover;flex-shrink:0;
|
||||
background:#111;border:1px solid #222;display:inline-block; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -658,12 +669,24 @@ function _loadUsers() {
|
||||
if (!_users.length) { empty.style.display = 'block'; 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
|
||||
// the list costs nothing for the 27 people here without a photo. _avatarBust changes after
|
||||
// an upload so the browser reloads rather than showing the cached previous face.
|
||||
const av = u.has_avatar
|
||||
? `<img class="vv-au-av" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(u.id)}&v=${_avatarBust}" alt="">`
|
||||
: `<span class="vv-au-av none">${_esc(_initials(u))}</span>`;
|
||||
// The real name, when it differs from the display name. 31 of 33 users here have first and
|
||||
// last set and none of it was on the page.
|
||||
const real = [u.firstName, u.lastName].filter(Boolean).join(' ');
|
||||
const sub = (real && real !== (u.displayName||'')) ? real : '';
|
||||
return `<div class="vv-au-user-row">
|
||||
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}</span>
|
||||
${av}
|
||||
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}${sub ? `<span class="vv-au-user-real">${_esc(sub)}</span>` : ''}</span>
|
||||
<span class="vv-au-user-email">${_esc(u.email||'')}</span>
|
||||
<div style="display:flex;gap:3px;align-items:center;flex-wrap:wrap">${grpBadges}</div>
|
||||
<div class="vv-au-user-acts">
|
||||
<button class="vv-au-icon-btn" data-user-grp="${_esc(u.id)}" title="Add to group">+grp</button>
|
||||
<button class="vv-au-icon-btn" data-user-photo="${_esc(u.id)}" title="Set or remove photo">🖼</button>
|
||||
<button class="vv-au-icon-btn" data-user-pass="${_esc(u.id)}" title="Change password">🔑</button>
|
||||
<button class="vv-au-icon-btn" data-user-edit="${_esc(u.id)}" title="Edit">✎</button>
|
||||
<button class="vv-au-icon-btn del" data-user-del="${_esc(u.id)}" title="Delete">✕</button>
|
||||
@@ -689,10 +712,26 @@ function _userModal(uid) {
|
||||
<label class="vv-au-label">Email</label>
|
||||
<input class="vv-au-input" id="um-email" type="email" value="${_esc(u?.email||'')}" placeholder="john@example.com">
|
||||
</div>
|
||||
<!-- lldap's full editable set for a user is display_name, mail, first_name, last_name and
|
||||
avatar. These two were the ones with nowhere to go, so the only way to correct a name was
|
||||
to open lldap's own WebUI. -->
|
||||
<div style="display:flex;gap:8px">
|
||||
<div class="vv-au-field" style="flex:1">
|
||||
<label class="vv-au-label">First Name</label>
|
||||
<input class="vv-au-input" id="um-first" value="${_esc(u?.firstName||'')}" placeholder="John">
|
||||
</div>
|
||||
<div class="vv-au-field" style="flex:1">
|
||||
<label class="vv-au-label">Last Name</label>
|
||||
<input class="vv-au-input" id="um-last" value="${_esc(u?.lastName||'')}" placeholder="Doe">
|
||||
</div>
|
||||
</div>
|
||||
${!u ? `<div class="vv-au-field">
|
||||
<label class="vv-au-label">Password</label>
|
||||
<input class="vv-au-input" id="um-pass" type="password" placeholder="Initial password">
|
||||
</div>` : ''}
|
||||
${u ? `<div class="vv-au-hint" style="margin-bottom:10px">
|
||||
created ${_esc(_fmtDate(u.creationDate))} · uuid <span style="font-family:monospace">${_esc(u.uuid||'—')}</span>
|
||||
</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>
|
||||
@@ -704,6 +743,8 @@ function _userModal(uid) {
|
||||
const id = (document.getElementById('um-uid').value||'').trim();
|
||||
const name = (document.getElementById('um-name').value||'').trim();
|
||||
const email= (document.getElementById('um-email').value||'').trim();
|
||||
const first= (document.getElementById('um-first').value||'').trim();
|
||||
const last = (document.getElementById('um-last').value||'').trim();
|
||||
const pass = document.getElementById('um-pass')?.value || '';
|
||||
if (!id) { _showModalErr('um-err','Username required'); return; }
|
||||
if (!email){ _showModalErr('um-err','Email required'); return; }
|
||||
@@ -715,8 +756,129 @@ function _userModal(uid) {
|
||||
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save':'Create'; return; }
|
||||
_closeModal(); _loadUsers();
|
||||
};
|
||||
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name }, done);
|
||||
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass }, done);
|
||||
// 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
|
||||
// attribute removal rather than an empty string.
|
||||
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name, first_name:first, last_name:last }, done);
|
||||
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass, first_name:first, last_name:last }, done);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
let _avatarBust = Date.now();
|
||||
|
||||
function _initials(u) {
|
||||
const s = ((u.firstName||'') + ' ' + (u.lastName||'')).trim() || u.displayName || u.id || '';
|
||||
return s.split(/\s+/).filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('') || '?';
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return isNaN(d) ? String(iso).slice(0,10) : d.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
// lldap types the avatar attribute JPEG_PHOTO and refuses anything else, so a PNG or a HEIC out of
|
||||
// a phone would be rejected on arrival. Everything is drawn to a canvas and re-encoded as JPEG
|
||||
// here instead, which means any format the browser can open is a valid import — and the same pass
|
||||
// bounds the size. The originals on this directory run to 273 KB for a picture shown at 22px;
|
||||
// 256px at q0.85 lands around 15 KB, and this value is read back on every user query forever.
|
||||
const VV_AVATAR_PX = 256, VV_AVATAR_Q = 0.85;
|
||||
|
||||
function _toJpegBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Could not read that file'));
|
||||
fr.onload = () => {
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error('That file is not an image the browser can open'));
|
||||
img.onload = () => {
|
||||
// Square, centre-cropped. An avatar is drawn in a square slot everywhere it appears, and
|
||||
// letterboxing it here would bake the padding into the stored image.
|
||||
const side = Math.min(img.width, img.height);
|
||||
const sx = (img.width - side) / 2, sy = (img.height - side) / 2;
|
||||
const px = Math.min(VV_AVATAR_PX, side);
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = px;
|
||||
const cx = cv.getContext('2d');
|
||||
// White rather than transparent: JPEG has no alpha, and a transparent PNG flattened onto
|
||||
// the default black reads as a photo of nothing.
|
||||
cx.fillStyle = '#fff'; cx.fillRect(0, 0, px, px);
|
||||
cx.drawImage(img, sx, sy, side, side, 0, 0, px, px);
|
||||
resolve(cv.toDataURL('image/jpeg', VV_AVATAR_Q).replace(/^data:image\/jpeg;base64,/, ''));
|
||||
};
|
||||
img.src = fr.result;
|
||||
};
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function _photoModal(uid) {
|
||||
const u = _users.find(x => x.id === uid);
|
||||
if (!u) return;
|
||||
const cur = u.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(u))}</span>`;
|
||||
|
||||
_modal(`<h3>Photo — ${_esc(u.displayName||uid)}</h3>
|
||||
<div style="display:flex;gap:14px;align-items:center;margin-bottom:12px">
|
||||
<div id="ph-preview">${cur}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<input class="vv-au-input" type="file" id="ph-file" accept="image/*">
|
||||
<div class="vv-au-hint">Any image the browser can open. Centre-cropped square, resized to
|
||||
${VV_AVATAR_PX}px and converted to JPEG — lldap stores nothing else.</div>
|
||||
<div class="vv-au-hint" id="ph-size"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-au-err" id="ph-err"></div>
|
||||
<div class="vv-au-modal-acts">
|
||||
${u.has_avatar ? '<button class="vv-au-btn danger" id="ph-remove" style="margin-right:auto">Remove photo</button>' : ''}
|
||||
<button class="vv-au-btn" id="ph-cancel">Cancel</button>
|
||||
<button class="vv-au-btn prim" id="ph-save" disabled>Save</button>
|
||||
</div>`);
|
||||
|
||||
let pending = null;
|
||||
document.getElementById('ph-file').addEventListener('change', async e => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
pending = await _toJpegBase64(f);
|
||||
// Rounded from the base64 length, which is the payload that actually travels and gets stored.
|
||||
const kb = Math.round(pending.length * 3 / 4 / 1024);
|
||||
document.getElementById('ph-size').textContent = `${f.name} → ${kb} KB JPEG`;
|
||||
document.getElementById('ph-preview').innerHTML =
|
||||
`<img class="vv-au-av big" src="data:image/jpeg;base64,${pending}" alt="">`;
|
||||
document.getElementById('ph-save').disabled = false;
|
||||
_showModalErr('ph-err', '');
|
||||
} catch (err) {
|
||||
pending = null;
|
||||
document.getElementById('ph-save').disabled = true;
|
||||
_showModalErr('ph-err', err.message || 'Could not read that image');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('ph-cancel').onclick = _closeModal;
|
||||
const rm = document.getElementById('ph-remove');
|
||||
if (rm) rm.onclick = async () => {
|
||||
if (!await vvConfirm('Remove this photo?')) return;
|
||||
rm.disabled = true;
|
||||
_post({ action:'lldap_remove_avatar', uid }, r => {
|
||||
if (!r.ok) { _showModalErr('ph-err', r.error||'Removal failed'); rm.disabled = false; return; }
|
||||
_avatarBust = Date.now(); _closeModal(); _loadUsers();
|
||||
});
|
||||
};
|
||||
document.getElementById('ph-save').onclick = () => {
|
||||
if (!pending) return;
|
||||
const btn = document.getElementById('ph-save');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
// Through _post, so URLSearchParams and never FormData — a multipart POST to this plugin's
|
||||
// endpoints hangs with no status ever returned, which is exactly the shape an upload invites.
|
||||
_post({ action:'lldap_set_avatar', uid, avatar: pending }, r => {
|
||||
if (!r.ok) { _showModalErr('ph-err', r.error||'Upload failed'); btn.disabled=false; btn.textContent='Save'; return; }
|
||||
_avatarBust = Date.now(); _closeModal(); _loadUsers();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -790,6 +952,9 @@ document.getElementById('vv-au-panel-users').addEventListener('click', async e =
|
||||
const passBtn = e.target.closest('[data-user-pass]');
|
||||
if (passBtn) { _passModal(passBtn.dataset.userPass); return; }
|
||||
|
||||
const photoBtn = e.target.closest('[data-user-photo]');
|
||||
if (photoBtn) { _photoModal(photoBtn.dataset.userPhoto); return; }
|
||||
|
||||
const grpBtn = e.target.closest('[data-user-grp]');
|
||||
if (grpBtn) { _addToGroupModal(grpBtn.dataset.userGrp); return; }
|
||||
|
||||
@@ -835,6 +1000,7 @@ function _loadGroups() {
|
||||
).join('');
|
||||
return `<div class="vv-au-grp-row" data-grp="${g.id}">
|
||||
<div class="vv-au-grp-acts">
|
||||
<button class="vv-au-icon-btn" data-group-ren="${g.id}" title="Rename group">✎</button>
|
||||
<button class="vv-au-icon-btn del" data-group-del="${g.id}" title="Delete group">✕</button>
|
||||
</div>
|
||||
<div class="vv-au-grp-name">${_esc(g.displayName)}</div>
|
||||
@@ -881,6 +1047,37 @@ document.getElementById('vv-au-panel-users').addEventListener('click', async e =
|
||||
return;
|
||||
}
|
||||
|
||||
const renGrp = e.target.closest('[data-group-ren]');
|
||||
if (renGrp) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(renGrp.dataset.groupRen);
|
||||
const grp = _groups.find(g => g.id === id);
|
||||
const name = await vvPrompt('Rename group', grp?.displayName || '');
|
||||
if (name === null) return;
|
||||
const trimmed = String(name).trim();
|
||||
if (!trimmed || trimmed === grp?.displayName) return;
|
||||
// Worth stopping on: the Access Control rules match groups by name, so a rename that the rules
|
||||
// do not follow leaves every rule naming a group nobody is in — which fails closed, silently,
|
||||
// for whoever was in it.
|
||||
//
|
||||
// Fetched rather than read from _rules, because _rules is only populated once the Access
|
||||
// Control tab has been opened. Renaming a group from a fresh page load would otherwise find an
|
||||
// empty list and report no rules affected, which is the reassuring answer and the wrong one.
|
||||
const rules = _rules.length ? _rules : await new Promise(res =>
|
||||
_get('authelia_rules', r => res((r && r.ok && r.rules) ? r.rules : [])));
|
||||
const used = rules.filter(r => _normList(r.subject).some(s =>
|
||||
_normList(s).some(v => String(v) === 'group:' + grp?.displayName)));
|
||||
if (used.length && !await vvConfirm(
|
||||
`"${grp.displayName}" is named by ${used.length} access-control rule${used.length>1?'s':''}. ` +
|
||||
`Renaming it here does not update ${used.length>1?'them':'it'} — you will need to edit ` +
|
||||
`${used.length>1?'those rules':'that rule'} on the Access Control tab too. Continue?`)) return;
|
||||
_post({ action:'lldap_rename_group', id, name: trimmed }, r => {
|
||||
if (!r.ok) { vvAlert('Rename failed: ' + (r.error||'unknown error')); return; }
|
||||
_loadGroups(); _loadUsers();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const delGrp = e.target.closest('[data-group-del]');
|
||||
if (delGrp) {
|
||||
e.stopPropagation();
|
||||
|
||||
Reference in New Issue
Block a user