feat(ui): redesign navigation for multi-user SaaS UX with pre/post-login visibility

- Inject is_logged_in, multi_user_enabled, auth_enabled into all templates
  via app/views/base.py _inject_global_context() helper
- Multi-user + logged-out: show only Pricing, About, Log In, Get Started
- Logged-in or single-user: full app nav (Dashboard, Upload, Files,
  Search, Pipelines, Admin dropdown, Status)
- Upload link is visually accented (blue) as the primary action
- Account dropdown (avatar, name, email, subscription, sign-out) for
  logged-in users in desktop and mobile
- Admin dropdown Similarity icon changed to purple to differentiate
  from Queue Monitor
- data-multi-user attribute on <body> so JS reads the mode at runtime
- 5 new unit tests for is_logged_in/multi_user_enabled injection

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 15:37:34 +00:00
parent 2b30b40a7e
commit dc1ee0e2e5
4 changed files with 500 additions and 173 deletions
+231 -121
View File
@@ -109,146 +109,256 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', fun
// Authentication status
// ---------------------------------------------------------------------------
/**
* Build a small icon + text menu item element.
* @private
* @param {string} href
* @param {string} iconClass - Font Awesome classes for the <i> element
* @param {string} label
* @param {string} [extraClasses] - additional classes for the <a>
*/
function _makeMenuLink(href, iconClass, label, extraClasses = '') {
const a = document.createElement('a');
a.href = href;
a.className = `flex items-center px-4 py-2 text-sm hover:bg-gray-50 ${extraClasses}`.trim();
a.setAttribute('role', 'menuitem');
const icon = document.createElement('i');
icon.className = `${iconClass} w-4 mr-2`;
icon.setAttribute('aria-hidden', 'true');
a.appendChild(icon);
a.appendChild(document.createTextNode(label));
return a;
}
// Check authentication status and update the auth section
(async function() {
console.log('Checking authentication status...');
(async function () {
try {
const response = await fetch('/api/auth/whoami');
const data = await response.json();
const authSection = document.getElementById('authSection');
const mobileAuthSection = document.getElementById('mobileAuthSection');
const authSection = document.getElementById("authSection");
const mobileAuthSection = document.getElementById("mobileAuthSection");
// If we have an email, user is authenticated (the whoami endpoint would have thrown 401 otherwise)
if (data.email) {
// Get the display name (prefer name, fall back to preferred_username, then email)
if (data && data.email) {
const displayName = data.name || data.preferred_username || data.email;
// Show admin menu items if the user is an admin
// ── Show admin menu items if the user is an admin ──────────────────────
if (data.is_admin) {
const adminMenuContainer = document.getElementById("adminMenuContainer");
if (adminMenuContainer) {
adminMenuContainer.classList.remove("hidden");
}
const mobileAdminSection = document.getElementById("mobileAdminSection");
if (mobileAdminSection) {
mobileAdminSection.classList.remove("hidden");
}
const adminMenuContainer = document.getElementById('adminMenuContainer');
if (adminMenuContainer) adminMenuContainer.classList.remove('hidden');
const mobileAdminSection = document.getElementById('mobileAdminSection');
if (mobileAdminSection) mobileAdminSection.classList.remove('hidden');
}
// User is logged in - use DOM API to prevent XSS
if (authSection) {
authSection.textContent = ''; // Clear existing content
const container = document.createElement('div');
container.className = 'flex items-center gap-2';
const img = document.createElement('img');
img.src = data.picture;
img.alt = 'Avatar';
img.className = 'w-8 h-8 rounded-full';
const span = document.createElement('span');
span.textContent = displayName;
// Subscription link
const planLink = document.createElement('a');
planLink.href = '/subscription';
planLink.className = 'text-xs text-indigo-600 hover:text-indigo-800 font-medium hidden md:inline';
planLink.title = 'My subscription';
const planIcon = document.createElement('i');
planIcon.className = 'fas fa-layer-group';
planLink.appendChild(planIcon);
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
logoutLink.className = 'text-red-600 hover:text-red-800';
const icon = document.createElement('i');
icon.className = 'fas fa-sign-out-alt';
logoutLink.appendChild(icon);
container.appendChild(img);
container.appendChild(span);
container.appendChild(planLink);
container.appendChild(logoutLink);
authSection.appendChild(container);
}
if (mobileAuthSection) {
mobileAuthSection.textContent = ''; // Clear existing content
const outerContainer = document.createElement('div');
outerContainer.className = 'flex items-center justify-between';
const innerContainer = document.createElement('div');
innerContainer.className = 'flex items-center';
const img = document.createElement('img');
img.src = data.picture;
img.alt = 'Avatar';
img.className = 'w-6 h-6 rounded-full mr-2';
const span = document.createElement('span');
span.textContent = displayName;
innerContainer.appendChild(img);
innerContainer.appendChild(span);
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
logoutLink.className = 'text-red-600 hover:text-red-800';
const icon = document.createElement('i');
icon.className = 'fas fa-sign-out-alt';
logoutLink.appendChild(icon);
logoutLink.appendChild(document.createTextNode(' Logout'));
outerContainer.appendChild(innerContainer);
outerContainer.appendChild(logoutLink);
mobileAuthSection.appendChild(outerContainer);
}
} else {
// User is not logged in - use DOM API
// ── Desktop: account dropdown ─────────────────────────────────────────
if (authSection) {
authSection.textContent = '';
const loginLink = document.createElement('a');
loginLink.href = '/login';
loginLink.className = 'text-blue-600';
loginLink.textContent = 'Login';
authSection.appendChild(loginLink);
// Wrapper positions the dropdown
const wrapper = document.createElement('div');
wrapper.className = 'relative';
// Toggle button
const btn = document.createElement('button');
btn.type = 'button';
btn.className =
'flex items-center gap-2 px-2 py-1 rounded-md text-gray-700 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500';
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-label', `Account menu for ${displayName}`);
const avatar = document.createElement('img');
avatar.src = data.picture;
avatar.alt = 'Avatar';
avatar.className = 'w-8 h-8 rounded-full';
const nameSpan = document.createElement('span');
nameSpan.className = 'hidden lg:inline text-sm font-medium max-w-[120px] truncate';
nameSpan.textContent = displayName;
const chevron = document.createElement('i');
chevron.className = 'fas fa-chevron-down text-xs text-gray-400';
chevron.setAttribute('aria-hidden', 'true');
btn.appendChild(avatar);
btn.appendChild(nameSpan);
btn.appendChild(chevron);
// Dropdown menu panel
const menu = document.createElement('div');
menu.className =
'absolute right-0 mt-2 w-52 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50 hidden';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Account menu');
// User info header
const header = document.createElement('div');
header.className = 'px-4 py-3 border-b border-gray-100';
const headerName = document.createElement('p');
headerName.className = 'text-sm font-semibold text-gray-800 truncate';
headerName.textContent = displayName;
const headerEmail = document.createElement('p');
headerEmail.className = 'text-xs text-gray-500 truncate';
headerEmail.textContent = data.email;
header.appendChild(headerName);
header.appendChild(headerEmail);
// Links section
const linksDiv = document.createElement('div');
linksDiv.className = 'py-1';
linksDiv.appendChild(
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
);
// Divider + Sign Out
const divider = document.createElement('div');
divider.className = 'border-t border-gray-100';
const signOutDiv = document.createElement('div');
signOutDiv.className = 'py-1';
signOutDiv.appendChild(
_makeMenuLink('/logout', 'fas fa-sign-out-alt text-red-400', 'Sign Out', 'text-red-600')
);
menu.appendChild(header);
menu.appendChild(linksDiv);
menu.appendChild(divider);
menu.appendChild(signOutDiv);
// Toggle behaviour
btn.addEventListener('click', function (e) {
e.stopPropagation();
const hidden = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
btn.setAttribute('aria-expanded', hidden ? 'true' : 'false');
});
document.addEventListener('click', function () {
menu.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
});
wrapper.appendChild(btn);
wrapper.appendChild(menu);
authSection.appendChild(wrapper);
}
// ── Mobile: user info + links ─────────────────────────────────────────
if (mobileAuthSection) {
mobileAuthSection.textContent = '';
const loginLink = document.createElement('a');
loginLink.href = '/login';
loginLink.className = 'text-blue-600';
loginLink.textContent = 'Login';
mobileAuthSection.appendChild(loginLink);
// User info row
const userRow = document.createElement('div');
userRow.className = 'flex items-center gap-3 px-3 py-3';
const mAvatar = document.createElement('img');
mAvatar.src = data.picture;
mAvatar.alt = 'Avatar';
mAvatar.className = 'w-8 h-8 rounded-full flex-shrink-0';
const mUserInfo = document.createElement('div');
mUserInfo.className = 'min-w-0';
const mName = document.createElement('p');
mName.className = 'text-sm font-semibold text-gray-800 truncate';
mName.textContent = displayName;
const mEmail = document.createElement('p');
mEmail.className = 'text-xs text-gray-500 truncate';
mEmail.textContent = data.email;
mUserInfo.appendChild(mName);
mUserInfo.appendChild(mEmail);
userRow.appendChild(mAvatar);
userRow.appendChild(mUserInfo);
mobileAuthSection.appendChild(userRow);
// Subscription link
const subLink = document.createElement('a');
subLink.href = '/subscription';
subLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
const subIcon = document.createElement('i');
subIcon.className = 'fas fa-layer-group mr-2 text-indigo-400';
subIcon.setAttribute('aria-hidden', 'true');
subLink.appendChild(subIcon);
subLink.appendChild(document.createTextNode('My Subscription'));
mobileAuthSection.appendChild(subLink);
// Logout link
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
logoutLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-red-600 hover:text-red-800 hover:bg-red-50';
const logoutIcon = document.createElement('i');
logoutIcon.className = 'fas fa-sign-out-alt mr-2';
logoutIcon.setAttribute('aria-hidden', 'true');
logoutLink.appendChild(logoutIcon);
logoutLink.appendChild(document.createTextNode('Sign Out'));
mobileAuthSection.appendChild(logoutLink);
}
} else {
// ── User is NOT logged in ─────────────────────────────────────────────
_renderLoggedOutAuth(authSection, mobileAuthSection);
}
} catch (error) {
console.error('Authentication check failed:', error);
// Fallback if whoami endpoint fails - use DOM API
const authSection = document.getElementById("authSection");
const mobileAuthSection = document.getElementById("mobileAuthSection");
if (authSection) {
authSection.textContent = '';
const loginLink = document.createElement('a');
loginLink.href = '/login';
loginLink.className = 'text-blue-600';
loginLink.textContent = 'Login';
authSection.appendChild(loginLink);
}
if (mobileAuthSection) {
mobileAuthSection.textContent = '';
const loginLink = document.createElement('a');
loginLink.href = '/login';
loginLink.className = 'text-blue-600';
loginLink.textContent = 'Login';
mobileAuthSection.appendChild(loginLink);
}
} catch (_err) {
// Fallback when whoami fails (network error, auth disabled, etc.)
const authSection = document.getElementById('authSection');
const mobileAuthSection = document.getElementById('mobileAuthSection');
_renderLoggedOutAuth(authSection, mobileAuthSection);
}
})();
// Other common functionality can be added here
/**
* Render the login / get-started buttons for unauthenticated visitors.
* Reads the data-multi-user attribute that the server injects on <body> to
* decide whether to show a prominent "Get Started" CTA alongside the login link.
*/
function _renderLoggedOutAuth(authSection, mobileAuthSection) {
const multiUser = document.body.getAttribute('data-multi-user') === 'true';
if (authSection) {
authSection.textContent = '';
const row = document.createElement('div');
row.className = 'flex items-center gap-2';
const loginLink = document.createElement('a');
loginLink.href = '/login';
loginLink.className =
'px-3 py-1.5 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 border border-gray-300';
loginLink.textContent = 'Log In';
row.appendChild(loginLink);
if (multiUser) {
const startLink = document.createElement('a');
startLink.href = '/pricing';
startLink.className =
'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500';
startLink.textContent = 'Get Started';
row.appendChild(startLink);
}
authSection.appendChild(row);
}
if (mobileAuthSection) {
mobileAuthSection.textContent = '';
const loginLink = document.createElement('a');
loginLink.href = '/login';
loginLink.className =
'block px-3 py-3 rounded-md text-base font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50';
const loginIcon = document.createElement('i');
loginIcon.className = 'fas fa-sign-in-alt mr-2';
loginIcon.setAttribute('aria-hidden', 'true');
loginLink.appendChild(loginIcon);
loginLink.appendChild(document.createTextNode('Log In'));
mobileAuthSection.appendChild(loginLink);
if (multiUser) {
const startLink = document.createElement('a');
startLink.href = '/pricing';
startLink.className =
'block px-3 py-3 rounded-md text-base font-medium text-white bg-blue-600 hover:text-white hover:bg-blue-700 mt-1';
const startIcon = document.createElement('i');
startIcon.className = 'fas fa-arrow-right mr-2';
startIcon.setAttribute('aria-hidden', 'true');
startLink.appendChild(startIcon);
startLink.appendChild(document.createTextNode('Get Started'));
mobileAuthSection.appendChild(startLink);
}
}
}