diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index 1fb5a600c..5d621a243 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -1,7 +1,7 @@ /** * Auth module tests */ -import { showLoginModal, showResetPasswordModal, updateUserUI, logout } from '../auth'; +import { showLoginModal, showResetPasswordModal, updateUserUI, logout, emailInitials } from '../auth'; import { ADMINISTRATORS_GROUP_ID } from '../permissions'; import { reloadPage } from '../utils'; @@ -460,6 +460,16 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display'); expect(userEmail?.textContent).toBe('test@example.com'); + // The avatar initials ride on a data attribute (drawn by CSS), so the + // button's text stays exactly the address. + expect(userEmail?.dataset['initials']).toBe('TE'); + }); + + test('emailInitials takes one letter from each of the first two name parts', () => { + expect(emailInitials('dana.okafor@example.com')).toBe('DO'); + expect(emailInitials('priya_raman@example.com')).toBe('PR'); + expect(emailInitials('admin@example.com')).toBe('AD'); + expect(emailInitials('x@example.com')).toBe('X'); }); test('shows user info when logged in', () => { @@ -881,6 +891,25 @@ describe('Auth Module', () => { expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Profile updated successfully', kind: 'success' })); }); + test('save profile refreshes the avatar initials with the new email', async () => { + (api.apiRequest as jest.Mock).mockResolvedValue({}); + updateUserUI(); + + const userEmail = document.getElementById('user-email-display') as HTMLElement; + const before = userEmail.dataset['initials']; + userEmail.click(); + await flushPromises(); + + (document.getElementById('profile-email') as HTMLInputElement).value = 'newemail@example.com'; + (document.getElementById('profile-current-password') as HTMLInputElement).value = 'oldpassword'; + document.getElementById('profile-form')?.dispatchEvent(new Event('submit', { cancelable: true })); + await flushPromises(); + + expect(userEmail.textContent).toBe('newemail@example.com'); + expect(userEmail.dataset['initials']).toBe('NE'); + expect(before).not.toBe('NE'); + }); + test('save profile handles API errors', async () => { (api.apiRequest as jest.Mock).mockRejectedValue(new Error('Update failed')); console.error = jest.fn(); diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index e625153f4..d826f6ae4 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -310,3 +310,43 @@ describe('syncHeaderPlacement', () => { expect(document.body.classList.contains('sidebar-open')).toBe(true); }); }); + +/** + * WCAG 2.4.3: keyboard order must follow visual order. Neither layout uses + * CSS `order` on the account cluster, so the DOM order of #user-info's + * children IS the visual order; syncHeaderPlacement owns it per layout. + * Built from the shipped index.html so the markup cannot drift from the test. + */ +describe('syncHeaderPlacement account cluster order (real index.html)', () => { + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + const body = /]*>([\s\S]*)<\/body>/.exec(html)![1]!.replace(//g, ''); + + beforeEach(() => { + document.body.innerHTML = body; + }); + afterEach(() => { + document.body.innerHTML = ''; + }); + + const childIds = (): string[] => + Array.from(document.getElementById('user-info')!.children).map( + el => el.id || (el.classList.contains('header-link') ? 'docs' : el.className), + ); + + test('drawer: who is signed in comes first in the DOM', () => { + syncHeaderPlacement(true); + expect(childIds()).toEqual([ + 'user-email-display', 'user-role-display', 'docs', 'feedback-link', 'user-info-divider', 'logout-btn', + ]); + }); + + test('topbar: help links, divider, then identity and Log out', () => { + syncHeaderPlacement(true); + syncHeaderPlacement(false); + expect(childIds()).toEqual([ + 'docs', 'feedback-link', 'user-info-divider', 'user-email-display', 'user-role-display', 'logout-btn', + ]); + }); +}); diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 1c6735f63..af903d728 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -943,6 +943,20 @@ function showResetEmailConfirmation(): void { document.getElementById('reset-confirmation-close')?.focus(); } +/** + * Up to two letters for the topbar avatar: the first letters of the first + * two dot/underscore/dash separated parts of the address's local part + * ("dana.okafor@..." -> "DO"), else its first two characters. + */ +export function emailInitials(email: string): string { + const local = email.split('@')[0] ?? ''; + const parts = local.split(/[._\-+]+/).filter(Boolean); + const letters = parts.length >= 2 + ? `${parts[0]!.charAt(0)}${parts[1]!.charAt(0)}` + : local.slice(0, 2); + return letters.toUpperCase(); +} + /** * Update user UI after login */ @@ -958,6 +972,8 @@ export function updateUserUI(): void { // Update the user email display with click-to-edit functionality if (userEmailEl) { userEmailEl.textContent = currentUser.email; + // Drawn as an avatar by CSS (::before), so it stays out of the text. + userEmailEl.dataset['initials'] = emailInitials(currentUser.email); userEmailEl.removeAttribute('aria-label'); userEmailEl.hidden = false; userEmailEl.title = 'Click to edit your profile'; @@ -1584,7 +1600,10 @@ async function saveProfile(e: Event): Promise { if (currentUser) { state.setCurrentUser({ ...currentUser, email }); const userEmailEl = document.getElementById('user-email-display'); - if (userEmailEl) userEmailEl.textContent = email; + if (userEmailEl) { + userEmailEl.textContent = email; + userEmailEl.dataset['initials'] = emailInitials(email); + } } closeProfileModal(); diff --git a/frontend/src/index.html b/frontend/src/index.html index 3c803e34d..7929a10b6 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -16,7 +16,7 @@ - + CUDly - Cloud Commitment Optimizer @@ -40,12 +40,16 @@

CUDly

+
- - API Docs - + + + +