diff --git a/Plugin/unraid/api/auth.php b/Plugin/unraid/api/auth.php
index 5abe0e5..e754a9c 100644
--- a/Plugin/unraid/api/auth.php
+++ b/Plugin/unraid/api/auth.php
@@ -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 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'] ?? ''),
diff --git a/Plugin/unraid/include/auth.php b/Plugin/unraid/include/auth.php
index a9f8ef0..595fcc7 100644
--- a/Plugin/unraid/include/auth.php
+++ b/Plugin/unraid/include/auth.php
@@ -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
, 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 } }',
diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php
index 2397f03..e653f86 100644
--- a/Plugin/unraid/pages/auth.php
+++ b/Plugin/unraid/pages/auth.php
@@ -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 => `${_esc(g.displayName)}`).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
+ ? `
`
+ : `${_esc(_initials(u))}`;
+ // 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 `