diff --git a/src/modules/core/components/user.avatar.component.vue b/src/modules/core/components/user.avatar.component.vue
index 3c3d14f36..8342874af 100644
--- a/src/modules/core/components/user.avatar.component.vue
+++ b/src/modules/core/components/user.avatar.component.vue
@@ -12,7 +12,7 @@
>
@@ -31,6 +31,15 @@
* Color is derived from email hash for consistency.
*/
+/**
+ * Allowed avatar sizes served by the backend `/api/uploads/images/` endpoint.
+ * Mirrors the sharp-resize whitelist in devkit Node
+ * `modules/uploads/config/uploads.{env}.config.js` (field `uploads.avatar.sharp.sizes`).
+ * Any value outside this list returns HTTP 422 "Wrong size param" — if you
+ * change one, update the other.
+ */
+const SHARP_SIZES = [128, 256, 512, 1024];
+
/**
* Determine whether a hex color is light (needs dark text for contrast).
* Uses relative luminance per WCAG 2.x.
@@ -80,6 +89,16 @@ export default {
hasAvatar() {
return !!(this.user && this.user.avatar && this.user.avatar !== '');
},
+ /**
+ * Snap the requested avatar size (doubled for retina) to the backend
+ * sharp whitelist. Any value outside `SHARP_SIZES` would return HTTP 422,
+ * so we pick the smallest allowed size that fits — or cap at the largest.
+ * @returns {number} A size from `SHARP_SIZES` (128, 256, 512, or 1024).
+ */
+ avatarSize() {
+ const requested = this.size * 2;
+ return SHARP_SIZES.find((s) => s >= requested) ?? SHARP_SIZES[SHARP_SIZES.length - 1];
+ },
initials() {
if (!this.user) return '';
const f = (this.user.firstName || '').charAt(0).toUpperCase();
diff --git a/src/modules/core/tests/user.avatar.component.unit.tests.js b/src/modules/core/tests/user.avatar.component.unit.tests.js
index 0dd80fe51..f7fce23c5 100644
--- a/src/modules/core/tests/user.avatar.component.unit.tests.js
+++ b/src/modules/core/tests/user.avatar.component.unit.tests.js
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
import UserAvatarComponent from '../components/user.avatar.component.vue';
const mockConfig = {
@@ -86,6 +86,47 @@ describe('user.avatar.component', () => {
});
});
+ describe('avatarSize computed', () => {
+ it('snaps size=24 (requested 48) to 128', () => {
+ const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 24 });
+ expect(wrapper.vm.avatarSize).toBe(128);
+ });
+
+ it('snaps size=64 (requested 128) to 128', () => {
+ const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 64 });
+ expect(wrapper.vm.avatarSize).toBe(128);
+ });
+
+ it('snaps size=200 (requested 400) to 512', () => {
+ const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 200 });
+ expect(wrapper.vm.avatarSize).toBe(512);
+ });
+
+ it('snaps size=512 (requested 1024) to 1024', () => {
+ const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 512 });
+ expect(wrapper.vm.avatarSize).toBe(1024);
+ });
+
+ it('caps size=600 (requested 1200) to 1024', () => {
+ const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 600 });
+ expect(wrapper.vm.avatarSize).toBe(1024);
+ });
+
+ it('uses snapped size when building avatar image URL', () => {
+ const setImages = vi.fn((api, file, size) => `${api.protocol}://${api.host}:${api.port}/api/uploads/images/${file}-${size}`);
+ const wrapper = mount(UserAvatarComponent, {
+ props: { user: { email: 'a@b.com', avatar: 'photo.jpg' }, size: 200 },
+ global: {
+ plugins: [vuetify],
+ config: { globalProperties: { config: mockConfig, setImages } },
+ },
+ });
+
+ expect(wrapper.vm.avatarSize).toBe(512);
+ expect(setImages).toHaveBeenCalledWith(mockConfig.api, 'photo.jpg', 512, null);
+ });
+ });
+
describe('hasAvatar computed', () => {
it('returns true when user has avatar', () => {
const wrapper = createWrapper({ user: { avatar: 'photo.jpg', email: 'a@b.com' } });