feat(user-avatar): users can now edit there image (image is taken from OIDC picture)

This commit is contained in:
Edouard Vanbelle
2026-05-26 00:50:38 +02:00
parent 8b6c8e345b
commit b0c5e7827e
43 changed files with 1563 additions and 250 deletions
+3 -12
View File
@@ -7,6 +7,7 @@ import { loadFiles } from './filesView.js';
import { updateStorageUsageDisplay } from './main.js';
import { app } from './state.js';
import { ui } from './ui.js';
import { updateUserMenuData } from './userMenu.js';
/**
* @import {User} from '../core/types.js'
@@ -96,14 +97,7 @@ async function checkAuthentication() {
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
// We have cached user data — render immediately, refresh in background
const userInitials = userData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach((el) => {
el.textContent = userInitials;
});
const menuName = document.getElementById('user-menu-name');
const menuEmail = document.getElementById('user-menu-email');
if (menuName) menuName.textContent = userData.username;
if (menuEmail) menuEmail.textContent = userData.email || '';
updateUserMenuData();
updateStorageUsageDisplay(userData);
@@ -145,10 +139,7 @@ async function checkAuthentication() {
try {
const freshData = await refreshUserData();
if (freshData?.username) {
const userInitials = freshData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach((el) => {
el.textContent = userInitials;
});
updateUserMenuData();
updateStorageUsageDisplay(freshData);
resolveHomeFolder().then(() => loadFiles());
} else {
+1 -1
View File
@@ -481,7 +481,7 @@ const ui = {
const id = cell.dataset.ownerId;
cell.dataset.ownerResolved = '1';
if (!id) continue;
cell.replaceChildren(createUserVignette(id, 'sm'));
cell.replaceChildren(createUserVignette(id, 'list'));
}
},
+33 -8
View File
@@ -2,6 +2,7 @@
* User menu, profile modal and logout logic
*/
import { createUserVignette } from '../components/userVignette.js';
import { getCsrfHeaders } from '../core/csrf.js';
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
@@ -21,6 +22,9 @@ function setupUserMenu() {
if (!wrapper || !avatarBtn || !menu) return;
// Populate avatar and name immediately from localStorage on every page load.
updateUserMenuData();
avatarBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = wrapper.classList.contains('open');
@@ -156,20 +160,41 @@ function setupUserMenu() {
fetchAppVersion();
}
/**
* Mount avatar-only vignettes for the toolbar button and the dropdown header.
* Called whenever user data in localStorage changes (login, photo save, etc.).
*
* The toolbar button (#user-avatar-btn) and the menu header (.user-menu-header)
* are the stable mount points. Both receive a fresh vignette each call so
* the photo / initials are always in sync with the current localStorage state.
*
* @param {string} userId
*/
function _mountAvatarVignettes(userId) {
const avatarBtn = document.getElementById('user-avatar-btn');
if (avatarBtn) {
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
}
const menuHeader = document.querySelector('.user-menu-header');
if (menuHeader) {
menuHeader.replaceChildren(createUserVignette(userId, 'xl', { showName: true, showEmail: true }));
}
}
/**
* @returns {void}
*/
function updateUserMenuData() {
const USER_DATA_KEY = 'oxicloud_user';
/** @type {import('../core/types.js').User} */
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const nameEl = document.getElementById('user-menu-name');
const emailEl = document.getElementById('user-menu-email');
const avatarEl = document.getElementById('user-menu-avatar');
const storageFill = document.getElementById('user-menu-storage-fill');
const storageText = document.getElementById('user-menu-storage-text');
if (userData.username) {
if (nameEl) nameEl.textContent = userData.username;
if (emailEl) emailEl.textContent = userData.email || '';
if (avatarEl) avatarEl.textContent = userData.username.substring(0, 2).toUpperCase();
if (userData.username && userData.id) {
_mountAvatarVignettes(userData.id);
}
const usedBytes = userData.storage_used_bytes || 0;
@@ -287,4 +312,4 @@ async function logout() {
window.location.href = '/login';
}
export { logout, setupUserMenu, showUserProfileModal };
export { logout, setupUserMenu, showUserProfileModal, updateUserMenuData };
+6 -45
View File
@@ -21,7 +21,7 @@ import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
import { Modal } from './modal.js';
import { _colorIndex, _initials } from './userVignette.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
@@ -337,27 +337,7 @@ const shareModal = {
item.className = 'smd-suggestion-item';
item.tabIndex = 0;
const avatar = document.createElement('div');
avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`;
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
avatar.textContent = _initials(displayName);
const nameEl = document.createElement('span');
nameEl.className = 'smd-suggestion-name';
nameEl.textContent = displayName;
const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
if (primaryEmail) {
const emailEl = document.createElement('span');
emailEl.className = 'smd-suggestion-email';
emailEl.textContent = primaryEmail;
item.appendChild(avatar);
item.appendChild(nameEl);
item.appendChild(emailEl);
} else {
item.appendChild(avatar);
item.appendChild(nameEl);
}
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
const select = () => onSelect(c);
item.addEventListener('click', select);
@@ -415,13 +395,7 @@ const shareModal = {
const chip = document.createElement('div');
chip.className = 'smd-chip';
const avatar = document.createElement('div');
avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`;
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
avatar.textContent = _initials(displayName);
const nameEl = document.createElement('span');
nameEl.textContent = displayName;
const vignette = createUserVignette(c.id, 'xs');
const rm = document.createElement('button');
rm.className = 'smd-chip-remove';
@@ -434,8 +408,7 @@ const shareModal = {
if (addBtn) addBtn.disabled = this._stagedUsers.length === 0;
});
chip.appendChild(avatar);
chip.appendChild(nameEl);
chip.appendChild(vignette);
chip.appendChild(rm);
container.appendChild(chip);
});
@@ -526,18 +499,7 @@ const shareModal = {
const row = document.createElement('div');
row.className = 'smd-member-row';
const avatar = document.createElement('div');
avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`;
// Resolve display name async
systemUsers.getDisplayName(entry.grant.subject.id).then((name) => {
avatar.textContent = _initials(name);
nameEl.textContent = name;
});
const nameEl = document.createElement('span');
nameEl.className = 'smd-member-name';
nameEl.textContent = `${entry.grant.subject.id.slice(0, 8)}…`;
const vignette = createUserVignette(entry.grant.subject.id, 'md');
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';
@@ -568,8 +530,7 @@ const shareModal = {
this._refreshMemberGroups();
});
row.appendChild(avatar);
row.appendChild(nameEl);
row.appendChild(vignette);
row.appendChild(roleSelect);
row.appendChild(removeBtn);
return row;
+91 -23
View File
@@ -1,16 +1,22 @@
// @ts-check
/**
* UserVignette — reusable user avatar + name inline component.
* UserVignette — reusable user avatar component, two display modes.
*
* Renders a coloured circle with initials (or photo when available) alongside
* an asynchronously-resolved display name. Used in:
* • Owner column (list view) via `ui.resolveOwnerCells()`
* • ShareModal member rows / chips / suggestion items
* Mode 1 — avatar + name (default):
* A coloured circle with initials (or photo) alongside an async-resolved
* display name. Used in the owner column, ShareModal rows / chips / items.
*
* Mode 2 — avatar only ({ showName: false }):
* The circle alone, no name span. Used in the user-menu toolbar button
* and the dropdown header where the name is rendered separately.
*
* Usage:
* import { createUserVignette } from './userVignette.js';
* // with name
* cell.replaceChildren(createUserVignette(userId, 'sm'));
* // avatar only
* btn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
*/
import { systemUsers } from '../model/systemUsers.js';
@@ -42,22 +48,60 @@ export function _colorIndex(userId) {
return Math.abs(hash) % 5;
}
/**
* Render a photo inside an avatar element, falling back to initials on error.
* @param {HTMLElement} avatar The `.user-vignette__avatar` element.
* @param {string} photoUrl Non-empty photo URL or data URI.
* @param {string} name Display name for the alt attribute / fallback.
*/
function _applyPhoto(avatar, photoUrl, name) {
const img = document.createElement('img');
img.alt = name;
img.src = photoUrl;
img.onerror = () => {
// Photo failed to load — fall back to initials
avatar.replaceChildren();
avatar.textContent = _initials(name);
};
avatar.replaceChildren(img);
}
// ── Component ──────────────────────────────────────────────────────────────────
/**
* @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize
* Available sizes. Each maps to a `.user-vignette--{size}` CSS modifier:
* xs → 20 px (chip avatar, small inline contexts)
* sm → 24 px (default; ShareModal suggestions, compact rows)
* list → 36 px (owner column in list view)
* md → 32 px (ShareModal member rows)
* lg → 40 px (profile page, larger lists)
* menu → 38 px (user-menu toolbar button)
* xl → 48 px (user-menu dropdown header)
*
* @typedef {'xs'|'sm'|'list'|'md'|'lg'|'menu'|'xl'} VignetteSize
*/
/**
* Create a user vignette element: a coloured initials circle + async-resolved
* display name span. The element is returned immediately with a short-UUID
* placeholder; the name resolves in the background via `systemUsers`.
* @typedef {Object} VignetteOptions
* @property {boolean} [showName=true]
* When false, only the avatar circle is rendered — no name span.
* Use this when the name is displayed separately (e.g. the user-menu header).
* @property {boolean} [showEmail=false]
* When true (and showName is true), the primary email address is shown below
* the name in a lighter style. Name and email are wrapped in a
* `.user-vignette__info` column. Has no effect when showName is false.
*/
/**
* Create a user vignette element. Returns immediately with a placeholder;
* the display name, email, and photo resolve asynchronously via `systemUsers`.
*
* @param {string} userId UUID of the user
* @param {VignetteSize} [size='sm']
* @param {string} userId UUID of the user
* @param {VignetteSize} [size='sm']
* @param {VignetteOptions} [options]
* @returns {HTMLElement}
*/
export function createUserVignette(userId, size = 'sm') {
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false } = {}) {
const colorIdx = _colorIndex(userId);
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
@@ -67,19 +111,43 @@ export function createUserVignette(userId, size = 'sm') {
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
// Temporary placeholder: first two chars of UUID
avatar.textContent = userId.slice(0, 2).toUpperCase();
const nameEl = document.createElement('span');
nameEl.className = 'user-vignette__name';
nameEl.textContent = `${userId.slice(0, 8)}…`;
wrapper.appendChild(avatar);
wrapper.appendChild(nameEl);
// Resolve full name asynchronously and update both avatar initials and name
systemUsers.getDisplayName(userId).then((name) => {
avatar.textContent = _initials(name);
nameEl.textContent = name;
});
/** @type {HTMLElement | null} */
const nameEl = showName ? document.createElement('span') : null;
/** @type {HTMLElement | null} */
const emailEl = showName && showEmail ? document.createElement('span') : null;
if (nameEl) {
nameEl.className = 'user-vignette__name';
nameEl.textContent = `${userId.slice(0, 8)}…`;
if (emailEl) {
// Wrap name + email in a column so they stack vertically.
emailEl.className = 'user-vignette__email';
const info = document.createElement('span');
info.className = 'user-vignette__info';
info.appendChild(nameEl);
info.appendChild(emailEl);
wrapper.appendChild(info);
} else {
wrapper.appendChild(nameEl);
}
}
// Resolve name, photo, and (when requested) email asynchronously.
Promise.all([systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null)]).then(
([name, photo, email]) => {
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email ?? '';
if (photo) {
_applyPhoto(avatar, photo, name);
} else {
avatar.textContent = _initials(name);
}
}
);
return wrapper;
}
+17 -3
View File
@@ -88,11 +88,25 @@ function installFetchInterceptor() {
return response;
}
// Auth endpoints must bypass retry: a 401 on /api/auth/* means the
// credentials themselves are invalid; retrying would cause a loop.
// True auth primitives must bypass retry — they would either loop
// (/refresh), or a 401 there genuinely means bad credentials (login,
// register, oidc, device flows). User-data endpoints that happen to
// live under /api/auth/ (me, me/image, change-password, app-passwords)
// ARE retried so that an expired access token is transparently refreshed.
// Public share endpoints (/api/s/) use 401 to mean "password required",
// not "session expired" — intercepting them would wrongly redirect to login.
if (urlStr.includes('/api/auth/') || urlStr.includes('/api/s/')) return response;
const AUTH_PRIMITIVES = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/',
'/api/auth/device/'
];
if (AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/')) {
return response;
}
const refreshed = await _refresh();
if (!refreshed) {
+8
View File
@@ -234,6 +234,10 @@ const OxiIcons = {
576,
'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z'
],
link: [
576,
'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'
],
list: [
512,
'M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z'
@@ -429,6 +433,10 @@ const OxiIcons = {
'volume-up': [
640,
'M533.6 32.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C557.5 113.8 592 180.8 592 256s-34.5 142.2-88.7 186.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C598.5 426.7 640 346.2 640 256S598.5 85.2 533.6 32.5zM473.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C475.3 170.7 496 210.9 496 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C393.1 227.6 400 241 400 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C434.1 312.9 448 286.1 448 256s-13.9-56.9-35.4-74.5zM80 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L128 160 80 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48z'
],
world: [
512,
'M351.9 280l-190.9 0c2.9 64.5 17.2 123.9 37.5 167.4 11.4 24.5 23.7 41.8 35.1 52.4 11.2 10.5 18.9 12.2 22.9 12.2s11.7-1.7 22.9-12.2c11.4-10.6 23.7-28 35.1-52.4 20.3-43.5 34.6-102.9 37.5-167.4zM160.9 232l190.9 0C349 167.5 334.7 108.1 314.4 64.6 303 40.2 290.7 22.8 279.3 12.2 268.1 1.7 260.4 0 256.4 0s-11.7 1.7-22.9 12.2c-11.4 10.6-23.7 28-35.1 52.4-20.3 43.5-34.6 102.9-37.5 167.4zm-48 0C116.4 146.4 138.5 66.9 170.8 14.7 78.7 47.3 10.9 131.2 1.5 232l111.4 0zM1.5 280c9.4 100.8 77.2 184.7 169.3 217.3-32.3-52.2-54.4-131.7-57.9-217.3L1.5 280zm398.4 0c-3.5 85.6-25.6 165.1-57.9 217.3 92.1-32.7 159.9-116.5 169.3-217.3l-111.4 0zm111.4-48C501.9 131.2 434.1 47.3 342 14.7 374.3 66.9 396.4 146.4 399.9 232l111.4 0z'
]
};
+74 -9
View File
@@ -4,9 +4,11 @@
* System-users convenience layer.
*
* Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that
* provides a userId → display-name index. Used wherever a grant's
* `granted_by` UUID needs to be shown as a human-readable name
* (owner tooltips, share dialogs, etc.).
* provides a userId → display-name index, a userId → photo-url index,
* and a userId → primary-email index.
* Used wherever a grant's `granted_by` UUID needs to be shown as a
* human-readable name (owner tooltips, share dialogs, etc.), avatar
* image (userVignette, user menu), or email (suggestion dropdowns).
*
* Falls back gracefully when the system address book is disabled
* server-side (`OXICLOUD_EXPOSE_SYSTEM_USERS` not set): `isAvailable()`
@@ -20,6 +22,12 @@ import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js';
/** @type {Map<string, string> | null} userId → display name, built lazily */
let _index = null;
/** @type {Map<string, string | null> | null} userId → photo URL (or null), built lazily */
let _photoIndex = null;
/** @type {Map<string, string | null> | null} userId → primary email (or null), built lazily */
let _emailIndex = null;
/**
* Derive the best human-readable name from a contact.
* Priority: "First Last" → full_name → primary email → shortened id.
@@ -36,7 +44,7 @@ function _nameFor(c) {
}
/**
* Ensure the index is built (idempotent).
* Ensure both indexes are built (idempotent).
* After loading contacts from the system address book, the current user
* (from localStorage) is injected so owner cells resolve correctly even
* when the server-side address book does not include the logged-in user.
@@ -46,15 +54,30 @@ async function _ensureIndex() {
if (_index !== null) return;
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
_index = new Map(contacts.map((c) => [c.id, _nameFor(c)]));
_photoIndex = new Map(contacts.map((c) => [c.id, c.photo_url ?? null]));
_emailIndex = new Map(
contacts.map((c) => {
const primary = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? null;
return [c.id, primary];
})
);
// Inject the current user if they are not already in the index
try {
const raw = localStorage.getItem('oxicloud_user');
if (raw) {
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw));
if (u?.id && !_index.has(u.id)) {
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
_index.set(u.id, name);
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string, image?:string|null}} */ (JSON.parse(raw));
if (u?.id) {
if (!_index.has(u.id)) {
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
_index.set(u.id, name);
}
if (!_photoIndex.has(u.id)) {
_photoIndex.set(u.id, u.image ?? null);
}
if (!_emailIndex.has(u.id)) {
_emailIndex.set(u.id, u.email ?? null);
}
}
}
} catch {
@@ -85,6 +108,48 @@ async function getDisplayName(userId) {
return _index?.get(userId) ?? `${userId.slice(0, 8)}…`;
}
/**
* Resolve a user UUID to a photo URL (or null if none set).
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
*
* @param {string} userId
* @returns {Promise<string | null>}
*/
async function getPhoto(userId) {
await _ensureIndex();
return _photoIndex?.get(userId) ?? null;
}
/**
* Resolve a user UUID to their primary email address (or null if unknown).
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
*
* @param {string} userId
* @returns {Promise<string | null>}
*/
async function getEmail(userId) {
await _ensureIndex();
return _emailIndex?.get(userId) ?? null;
}
/**
* Force-refresh the current user's photo entry in the index from localStorage.
* Call this after saving a new avatar on the profile page so that existing
* vignettes can re-render without a full page reload.
*/
function refreshCurrentUserPhoto() {
try {
const raw = localStorage.getItem('oxicloud_user');
if (!raw || !_photoIndex) return;
const u = /** @type {{id?:string, image?:string|null}} */ (JSON.parse(raw));
if (u?.id) {
_photoIndex.set(u.id, u.image ?? null);
}
} catch {
// ignore
}
}
/**
* Returns `false` only after a confirmed 404 from the server (feature
* disabled). Returns `true` when status is unknown or the book loaded OK.
@@ -94,4 +159,4 @@ function isAvailable() {
return addressBook.isSystemAvailable();
}
export const systemUsers = { prefetch, getDisplayName, isAvailable };
export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
+96
View File
@@ -0,0 +1,96 @@
// @ts-check
/**
* Client-side image resize utility.
*
* Accepts a File/Blob, resizes it to fit within maxSize × maxSize pixels
* (never upscales), and returns a data URI (WebP preferred, JPEG fallback).
*
* Used by the profile page before uploading an avatar image so that
* data URIs stay well within the 512 KiB backend limit.
*/
/** Accepted MIME types for avatar uploads. */
const ACCEPTED_TYPES = new Set(['image/png', 'image/webp', 'image/jpeg']);
/**
* Load an image File/Blob as a data URL via FileReader.
* @param {File | Blob} file
* @returns {Promise<string>}
*/
function _readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(/** @type {string} */ (reader.result));
reader.onerror = () => reject(new Error('FileReader failed'));
reader.readAsDataURL(file);
});
}
/**
* Load a data URL into an HTMLImageElement (waits for `onload`).
* @param {string} src
* @returns {Promise<HTMLImageElement>}
*/
function _loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image failed to load'));
img.src = src;
});
}
/**
* Convert a canvas to a data URI, preferring WebP at quality 0.85.
* Falls back to JPEG if the browser does not support WebP encoding.
* @param {HTMLCanvasElement} canvas
* @returns {string}
*/
function _canvasToDataUri(canvas) {
const webp = canvas.toDataURL('image/webp', 0.85);
// toDataURL returns a PNG if the MIME type is not supported — detect by prefix
if (webp.startsWith('data:image/webp')) return webp;
return canvas.toDataURL('image/jpeg', 0.85);
}
/**
* Resize an image File to fit within maxSize × maxSize, then return
* a data URI (WebP at quality 0.85, or JPEG as fallback).
*
* - Images already within maxSize × maxSize are not upscaled.
* - Only `image/png`, `image/webp`, and `image/jpeg` are accepted;
* all other MIME types throw an Error.
*
* @param {File} file Image file to resize
* @param {number} [maxSize=512] Maximum width and height in pixels
* @returns {Promise<string>} data URI of the (possibly resized) image
*/
export async function resizeImageToDataUrl(file, maxSize = 104) {
if (!ACCEPTED_TYPES.has(file.type)) {
throw new Error(`Unsupported image type: ${file.type}. Accepted: PNG, WebP, JPEG.`);
}
const dataUrl = await _readAsDataUrl(file);
const img = await _loadImage(dataUrl);
const { naturalWidth: w, naturalHeight: h } = img;
// Compute output dimensions — scale down proportionally if needed, never upscale
let outW = w;
let outH = h;
if (w > maxSize || h > maxSize) {
const ratio = Math.min(maxSize / w, maxSize / h);
outW = Math.round(w * ratio);
outH = Math.round(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not get 2D canvas context');
ctx.drawImage(img, 0, 0, outW, outH);
return _canvasToDataUri(canvas);
}
+255 -1
View File
@@ -1,6 +1,12 @@
import { getCsrfHeaders } from '../../core/csrf.js';
import { installFetchInterceptor } from '../../core/fetchWrapper.js';
import { i18n } from '../../core/i18n.js';
import { oxiIconsInit } from '../../core/icons.js';
import { resizeImageToDataUrl } from '../../utils/imageResize.js';
// Install the fetch interceptor so expired access tokens are refreshed
// automatically on this standalone page (it is not loaded by main.js here).
installFetchInterceptor();
const API = '/api';
@@ -36,6 +42,240 @@ function timeAgo(dateStr) {
return d.toLocaleDateString();
}
// ── Avatar helpers ─────────────────────────────────────────────────────────────
/**
* Render the large profile avatar (#p-avatar) — photo or initials.
* @param {string | null | undefined} photo
* @param {string} initials
*/
function _renderAvatar(photo, initials) {
const avatarEl = document.getElementById('p-avatar');
if (!avatarEl) return;
if (photo) {
const img = document.createElement('img');
img.alt = initials;
img.src = photo;
img.onerror = () => {
avatarEl.replaceChildren();
avatarEl.textContent = initials;
};
avatarEl.replaceChildren(img);
} else {
avatarEl.replaceChildren();
avatarEl.textContent = initials;
}
}
/**
* Persist user data to localStorage and refresh the top-right avatar.
* Calls GET /api/auth/me to get the fresh user object.
* @returns {Promise<void>}
*/
async function _refreshUserCache() {
try {
const resp = await fetch(`${API}/auth/me`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) return;
const user = await resp.json();
localStorage.setItem('oxicloud_user', JSON.stringify(user));
// Refresh top-right avatars if userMenu module is loaded on this page
// (profile.html is a standalone page, userMenu is only in index.html)
// — so we update #user-avatar / #user-menu-avatar directly if present
const initials = (user.username || '?').substring(0, 2).toUpperCase();
const topEl = /** @type {HTMLElement|null} */ (document.getElementById('user-avatar'));
const dropEl = /** @type {HTMLElement|null} */ (document.getElementById('user-menu-avatar'));
if (topEl || dropEl) {
/** @param {HTMLElement|null} el */
function applyPhoto(el) {
if (!el) return;
if (user.image) {
const img = document.createElement('img');
img.alt = initials;
img.src = user.image;
img.onerror = () => {
el.replaceChildren();
el.textContent = initials;
};
el.replaceChildren(img);
} else {
el.replaceChildren();
el.textContent = initials;
}
}
applyPhoto(topEl);
applyPhoto(dropEl);
}
} catch (_) {
// Best-effort
}
}
// ── Photo edit panel ────────────────────────────────────────────────────────────
/** @type {string|null} Pending data URI from file upload (upload mode) */
let _uploadedDataUri = null;
/**
* Switch the visible edit tab.
* @param {'url'|'upload'} tab
*/
function _switchTab(tab) {
const urlPane = document.getElementById('p-pane-url');
const uploadPane = document.getElementById('p-pane-upload');
const urlBtn = document.getElementById('p-tab-url');
const uploadBtn = document.getElementById('p-tab-upload');
if (tab === 'url') {
urlPane?.classList.remove('hidden');
uploadPane?.classList.add('hidden');
urlBtn?.classList.add('active');
uploadBtn?.classList.remove('active');
} else {
urlPane?.classList.add('hidden');
uploadPane?.classList.remove('hidden');
urlBtn?.classList.remove('active');
uploadBtn?.classList.add('active');
}
}
function _openEditPanel() {
document.getElementById('p-avatar-edit-panel')?.classList.remove('hidden');
_switchTab('url');
_uploadedDataUri = null;
const preview = /** @type {HTMLImageElement|null} */ (document.getElementById('p-image-preview'));
if (preview) {
preview.src = '';
preview.classList.add('hidden');
}
const urlInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-url'));
if (urlInput) urlInput.value = '';
const status = document.getElementById('p-avatar-status');
if (status) status.innerHTML = '';
}
function _closeEditPanel() {
document.getElementById('p-avatar-edit-panel')?.classList.add('hidden');
_uploadedDataUri = null;
}
/**
* Send PUT /api/auth/me/image and update UI on success.
* @param {string | null} image
*/
async function _saveImage(image) {
const statusEl = document.getElementById('p-avatar-status');
const saveBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById('p-avatar-save'));
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i>`;
}
if (statusEl) statusEl.innerHTML = '';
try {
const resp = await fetch(`${API}/auth/me/image`, {
method: 'PUT',
headers: headers(),
credentials: 'same-origin',
body: JSON.stringify({ image })
});
if (resp.ok) {
await _refreshUserCache();
// Update large avatar immediately
const raw = localStorage.getItem('oxicloud_user');
const user = raw ? JSON.parse(raw) : null;
const initials = (user?.username || '?').substring(0, 2).toUpperCase();
_renderAvatar(user?.image, initials);
_closeEditPanel();
} else {
const err = await resp.json().catch(() => ({}));
if (statusEl) {
statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(err.message || err.error || i18n.t('profile.photo_save_failed')) +
'</div>';
}
}
} catch (err) {
if (statusEl) {
statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message })) +
'</div>';
}
} finally {
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('profile.photo_save'))}`;
}
}
}
function _setupPhotoEdit() {
const editBtn = document.getElementById('p-avatar-edit-btn');
const cancelBtn = document.getElementById('p-avatar-cancel');
const saveBtn = document.getElementById('p-avatar-save');
const removeBtn = document.getElementById('p-avatar-remove');
const tabUrl = document.getElementById('p-tab-url');
const tabUpload = document.getElementById('p-tab-upload');
const fileInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-file'));
editBtn?.addEventListener('click', _openEditPanel);
cancelBtn?.addEventListener('click', _closeEditPanel);
tabUrl?.addEventListener('click', () => {
_switchTab('url');
});
tabUpload?.addEventListener('click', () => {
_switchTab('upload');
});
saveBtn?.addEventListener('click', async () => {
const activePane = document.getElementById('p-pane-url')?.classList.contains('hidden') ? 'upload' : 'url';
if (activePane === 'url') {
const urlInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-url'));
const val = urlInput?.value.trim() || null;
await _saveImage(val || null);
} else {
if (!_uploadedDataUri) {
const status = document.getElementById('p-avatar-status');
if (status)
status.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.photo_no_file'))}</div>`;
return;
}
await _saveImage(_uploadedDataUri);
}
});
removeBtn?.addEventListener('click', async () => {
await _saveImage(null);
});
fileInput?.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
const status = document.getElementById('p-avatar-status');
if (status) status.innerHTML = '';
try {
const dataUri = await resizeImageToDataUrl(file, 104);
_uploadedDataUri = dataUri;
const preview = /** @type {HTMLImageElement|null} */ (document.getElementById('p-image-preview'));
if (preview) {
preview.src = dataUri;
preview.classList.remove('hidden');
}
} catch (err) {
_uploadedDataUri = null;
if (status) {
status.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(/** @type {Error} */ (err).message)}</div>`;
}
}
});
}
async function init() {
try {
oxiIconsInit();
@@ -50,7 +290,7 @@ async function init() {
const user = await resp.json();
const initials = (user.username || '?').substring(0, 2).toUpperCase();
document.getElementById('p-avatar').textContent = initials;
_renderAvatar(user.image, initials);
document.getElementById('p-username').textContent = user.username;
document.getElementById('p-email').textContent = user.email || '';
@@ -63,6 +303,17 @@ async function init() {
badge.innerHTML = `<i class="fas fa-user"></i> ${i18n.t('profile.role_user')}`;
}
// Photo edit controls
const isLocal = !user.auth_provider || user.auth_provider === 'local';
const editBtn = document.getElementById('p-avatar-edit-btn');
const oidcNote = document.getElementById('p-avatar-oidc-note');
if (user.can_edit_image && isLocal) {
editBtn?.classList.remove('hidden');
} else if (!isLocal && user.image) {
// OIDC user with a photo: show note, no edit button
oidcNote?.classList.remove('hidden');
}
document.getElementById('p-detail-username').textContent = user.username;
document.getElementById('p-detail-email').textContent = user.email || '—';
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? i18n.t('profile.role_admin') : i18n.t('profile.role_user');
@@ -361,6 +612,9 @@ document.getElementById('app-pw-generate').addEventListener('click', createAppPa
document.getElementById('app-pw-copy-btn').addEventListener('click', copyAppPassword);
document.getElementById('app-pw-auto-toggle').addEventListener('click', toggleAutoPasswords);
/* Photo-edit panel — wired once at module load, not per init() call */
_setupPhotoEdit();
/* Re-render when language changes */
window.addEventListener('translationsLoaded', () => {
init();