From dc1ee0e2e5b5e4328783bc0f9c92ac8af546fd88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:37:34 +0000 Subject: [PATCH] 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 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> --- app/views/base.py | 40 +-- frontend/static/js/common.js | 352 +++++++++++++++++--------- frontend/templates/base.html | 155 +++++++++--- tests/test_coverage_remaining_gaps.py | 126 +++++++++ 4 files changed, 500 insertions(+), 173 deletions(-) diff --git a/app/views/base.py b/app/views/base.py index 941c16ed..13835960 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -25,26 +25,36 @@ templates.env.globals["max"] = max original_template_response = templates.TemplateResponse +def _inject_global_context(ctx: dict) -> None: + """Inject shared global variables into every template context dict.""" + ctx.setdefault("version", settings.version) + ctx.setdefault("release_name", getattr(settings, "release_name", None)) + ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) + ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False)) + ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True)) + + req = ctx.get("request") + if req is not None: + # CSRF token + if hasattr(req, "state") and hasattr(req.state, "csrf_token"): + ctx.setdefault("csrf_token", req.state.csrf_token) + # Determine whether the current visitor is authenticated + session_user = None + if hasattr(req, "session"): + session_user = req.session.get("user") + # When auth is disabled every visitor is effectively "logged in" + ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None) + else: + ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True)) + + def template_response_with_version(*args, **kwargs): """Wrapper for TemplateResponse to include version and CSRF token in all templates""" # If context dict is provided, add version to it if len(args) >= 2 and isinstance(args[1], dict): - args[1].setdefault("version", settings.version) - args[1].setdefault("release_name", getattr(settings, "release_name", None)) - # Inject CSRF token from request state when available - req = args[1].get("request") - if req is not None and hasattr(req.state, "csrf_token"): - args[1].setdefault("csrf_token", req.state.csrf_token) - # Inject default color scheme for dark-mode initialisation - args[1].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) + _inject_global_context(args[1]) elif "context" in kwargs and isinstance(kwargs["context"], dict): - kwargs["context"].setdefault("version", settings.version) - kwargs["context"].setdefault("release_name", getattr(settings, "release_name", None)) - req = kwargs["context"].get("request") - if req is not None and hasattr(req.state, "csrf_token"): - kwargs["context"].setdefault("csrf_token", req.state.csrf_token) - # Inject default color scheme for dark-mode initialisation - kwargs["context"].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) + _inject_global_context(kwargs["context"]) return original_template_response(*args, **kwargs) diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index b6bc6019..2d9580fe 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -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 element + * @param {string} label + * @param {string} [extraClasses] - additional classes for the + */ +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 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); + } + } +} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 49af2bad..af1c1414 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -33,7 +33,8 @@ - + @@ -56,30 +57,69 @@ - +
-