Merge pull request #496 from christianlouis/copilot/redesign-menu-for-logged-out-users
fix(ci): apply ruff format to test file added in navigation redesign
This commit is contained in:
+25
-15
@@ -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)
|
||||
|
||||
|
||||
|
||||
+231
-121
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-37
@@ -33,7 +33,8 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token | default('', true) }}">
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col"
|
||||
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}">
|
||||
<!-- Skip to main content link for keyboard/screen reader users -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
@@ -56,30 +57,69 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Menu Items - using x-data for mobile menu toggle -->
|
||||
<!-- ── Navigation: uses x-data for mobile menu + dropdowns ─────────── -->
|
||||
<div x-data="{ mobileMenuOpen: false, adminMenuOpen: false }">
|
||||
<div class="hidden md:flex space-x-4 items-center">
|
||||
<a href="/" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/' %}aria-current="page"{% endif %}>Home</a>
|
||||
<a href="/upload" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>Upload</a>
|
||||
<a href="/files" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>Files</a>
|
||||
<a href="/search" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>Search</a>
|
||||
<a href="/pipelines" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>Pipelines</a>
|
||||
<a href="/pricing" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>Pricing</a>
|
||||
|
||||
<!-- Admin dropdown (shown only for admin users via JS) -->
|
||||
<!-- ── DESKTOP navigation ──────────────────────────────────────────── -->
|
||||
<div class="hidden md:flex space-x-1 items-center">
|
||||
|
||||
{% if multi_user_enabled and not is_logged_in %}
|
||||
{# ── Public / marketing nav (multi-user, visitor not signed in) ── #}
|
||||
|
||||
<a href="/pricing"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>
|
||||
Pricing
|
||||
</a>
|
||||
<a href="/about"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/about' %}aria-current="page"{% endif %}>
|
||||
About
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
{# ── App nav (logged-in or single-user / auth-disabled mode) ────── #}
|
||||
|
||||
<a href="/"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-home mr-1 text-gray-400" aria-hidden="true"></i>Dashboard
|
||||
</a>
|
||||
<a href="/upload"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
{% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-upload mr-1" aria-hidden="true"></i>Upload
|
||||
</a>
|
||||
<a href="/files"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-folder-open mr-1 text-gray-400" aria-hidden="true"></i>Files
|
||||
</a>
|
||||
<a href="/search"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-search mr-1 text-gray-400" aria-hidden="true"></i>Search
|
||||
</a>
|
||||
<a href="/pipelines"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-project-diagram mr-1 text-gray-400" aria-hidden="true"></i>Pipelines
|
||||
</a>
|
||||
|
||||
<!-- Admin dropdown – shown only for admin users via JS -->
|
||||
<div id="adminMenuContainer" class="relative hidden">
|
||||
<button
|
||||
@click="adminMenuOpen = !adminMenuOpen"
|
||||
@click.outside="adminMenuOpen = false"
|
||||
type="button"
|
||||
class="inline-flex items-center text-gray-700 hover:text-gray-900 focus:outline-none"
|
||||
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-haspopup="true"
|
||||
aria-label="Admin menu"
|
||||
:aria-expanded="adminMenuOpen"
|
||||
>
|
||||
<i class="fas fa-shield-alt mr-1 text-red-500"></i>
|
||||
<i class="fas fa-shield-alt mr-1 text-red-500" aria-hidden="true"></i>
|
||||
Admin
|
||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
<i class="fas fa-chevron-down ml-1 text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div
|
||||
x-show="adminMenuOpen"
|
||||
@@ -89,7 +129,7 @@
|
||||
x-transition:leave="transition ease-in duration-75 transform"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
class="absolute left-0 mt-2 w-52 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
aria-label="Admin actions"
|
||||
>
|
||||
@@ -109,11 +149,12 @@
|
||||
<a href="/admin/files" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> File Manager
|
||||
</a>
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<a href="/duplicates" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-clone w-4 mr-2 text-orange-500" aria-hidden="true"></i> Duplicates
|
||||
</a>
|
||||
<a href="/similarity" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-project-diagram w-4 mr-2 text-blue-500" aria-hidden="true"></i> Similarity
|
||||
<i class="fas fa-sitemap w-4 mr-2 text-purple-500" aria-hidden="true"></i> Similarity
|
||||
</a>
|
||||
<a href="/admin/queue" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-stream w-4 mr-2 text-blue-500" aria-hidden="true"></i> Queue Monitor
|
||||
@@ -122,14 +163,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status (de-emphasized) -->
|
||||
<a href="/status" class="text-gray-500 hover:text-gray-700 text-sm" title="System Status">
|
||||
<!-- Status (de-emphasised, shown after Admin) -->
|
||||
<a href="/status"
|
||||
class="px-2 py-2 rounded-md text-xs font-medium text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
title="System Status"
|
||||
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-circle-dot mr-0.5" aria-hidden="true"></i> Status
|
||||
</a>
|
||||
|
||||
<a href="/about" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/about' %}aria-current="page"{% endif %}>About</a>
|
||||
{% endif %}{# end multi_user_enabled / is_logged_in check #}
|
||||
|
||||
<!-- Dark mode toggle (desktop) -->
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
id="darkModeToggle"
|
||||
onclick="toggleDarkMode()"
|
||||
@@ -141,11 +185,11 @@
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
|
||||
<!-- Dynamic Auth Section -->
|
||||
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
|
||||
<!-- Dynamic Auth Section (populated by common.js) -->
|
||||
<div id="authSection"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<!-- ── Mobile menu button ──────────────────────────────────────────── -->
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
type="button"
|
||||
@@ -160,7 +204,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile menu, show/hide based on menu state -->
|
||||
<!-- ── MOBILE menu ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="mobileMenuOpen"
|
||||
x-transition:enter="transition ease-out duration-100 transform"
|
||||
@@ -172,14 +216,49 @@
|
||||
class="md:hidden absolute top-16 inset-x-0 bg-white shadow-md z-50"
|
||||
>
|
||||
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||
<a href="/" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
|
||||
<a href="/upload" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
|
||||
<a href="/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
|
||||
<a href="/search" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Search</a>
|
||||
<a href="/pipelines" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Pipelines</a>
|
||||
<a href="/pricing" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Pricing</a>
|
||||
|
||||
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
|
||||
{% if multi_user_enabled and not is_logged_in %}
|
||||
{# ── Public / marketing links ────────────────────────────────── #}
|
||||
<a href="/pricing"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-tag mr-2 text-gray-400" aria-hidden="true"></i>Pricing
|
||||
</a>
|
||||
<a href="/about"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/about' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-info-circle mr-2 text-gray-400" aria-hidden="true"></i>About
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
{# ── App links (logged-in or single-user) ────────────────────── #}
|
||||
<a href="/"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-home mr-2 text-gray-400" aria-hidden="true"></i>Dashboard
|
||||
</a>
|
||||
<a href="/upload"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
{% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-upload mr-2" aria-hidden="true"></i>Upload
|
||||
</a>
|
||||
<a href="/files"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i>Files
|
||||
</a>
|
||||
<a href="/search"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-search mr-2 text-gray-400" aria-hidden="true"></i>Search
|
||||
</a>
|
||||
<a href="/pipelines"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-project-diagram mr-2 text-gray-400" aria-hidden="true"></i>Pipelines
|
||||
</a>
|
||||
|
||||
<!-- Admin section in mobile menu – shown only for admin users via JS -->
|
||||
<div id="mobileAdminSection" class="hidden">
|
||||
<div class="border-t border-gray-200 mt-1 pt-1">
|
||||
<p class="px-3 py-1 text-xs font-semibold text-red-600 uppercase tracking-wider flex items-center">
|
||||
@@ -204,7 +283,7 @@
|
||||
<i class="fas fa-clone mr-2 text-orange-400" aria-hidden="true"></i> Duplicates
|
||||
</a>
|
||||
<a href="/similarity" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-project-diagram mr-2 text-blue-400" aria-hidden="true"></i> Similarity
|
||||
<i class="fas fa-sitemap mr-2 text-purple-400" aria-hidden="true"></i> Similarity
|
||||
</a>
|
||||
<a href="/admin/queue" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-stream mr-2 text-blue-400" aria-hidden="true"></i> Queue Monitor
|
||||
@@ -212,12 +291,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status (de-emphasized) in mobile -->
|
||||
<a href="/status" class="block px-3 py-3 rounded-md text-sm font-medium text-gray-400 hover:text-gray-600 hover:bg-gray-50">
|
||||
<!-- Status (de-emphasised) -->
|
||||
<a href="/status"
|
||||
class="block px-3 py-3 rounded-md text-sm font-medium text-gray-400 hover:text-gray-600 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-circle-dot mr-1" aria-hidden="true"></i> Status
|
||||
</a>
|
||||
|
||||
<a href="/about" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">About</a>
|
||||
{% endif %}{# end multi_user_enabled / is_logged_in check #}
|
||||
|
||||
<!-- Dark mode toggle (mobile) -->
|
||||
<button
|
||||
@@ -227,12 +308,12 @@
|
||||
id="darkModeToggleMobile"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<i class="fas fa-moon mr-2" id="darkModeIconMobile"></i>
|
||||
<i class="fas fa-moon mr-2" id="darkModeIconMobile" aria-hidden="true"></i>
|
||||
<span id="darkModeTextMobile">Dark Mode</span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Auth Section -->
|
||||
<div id="mobileAuthSection" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<!-- Mobile Auth / Account section (populated by common.js) -->
|
||||
<div id="mobileAuthSection" class="border-t border-gray-100 pt-1 mt-1">
|
||||
<!-- Will be populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,137 @@ class TestViewsBase:
|
||||
|
||||
mock_orig.assert_called_once()
|
||||
|
||||
def test_is_logged_in_injected_when_auth_disabled(self):
|
||||
"""is_logged_in should be True when auth is disabled (single-user mode)."""
|
||||
from app.views.base import template_response_with_version
|
||||
|
||||
with (
|
||||
patch("app.views.base.original_template_response") as mock_orig,
|
||||
patch("app.views.base.settings") as mock_settings,
|
||||
):
|
||||
mock_orig.return_value = "response"
|
||||
mock_settings.auth_enabled = False
|
||||
mock_settings.multi_user_enabled = False
|
||||
mock_settings.version = "0.0.0"
|
||||
mock_settings.release_name = None
|
||||
mock_settings.ui_default_color_scheme = "system"
|
||||
|
||||
req = MagicMock()
|
||||
del req.state.csrf_token
|
||||
|
||||
template_response_with_version(
|
||||
"template.html",
|
||||
context={"request": req},
|
||||
)
|
||||
|
||||
_, kwargs = mock_orig.call_args
|
||||
assert kwargs["context"].get("is_logged_in") is True
|
||||
|
||||
def test_is_logged_in_true_when_session_user_present(self):
|
||||
"""is_logged_in should be True when session contains a user."""
|
||||
from app.views.base import template_response_with_version
|
||||
|
||||
with (
|
||||
patch("app.views.base.original_template_response") as mock_orig,
|
||||
patch("app.views.base.settings") as mock_settings,
|
||||
):
|
||||
mock_orig.return_value = "response"
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.multi_user_enabled = True
|
||||
mock_settings.version = "0.0.0"
|
||||
mock_settings.release_name = None
|
||||
mock_settings.ui_default_color_scheme = "system"
|
||||
|
||||
req = MagicMock()
|
||||
req.session.get.return_value = {"email": "user@example.com"}
|
||||
del req.state.csrf_token
|
||||
|
||||
template_response_with_version(
|
||||
"template.html",
|
||||
context={"request": req},
|
||||
)
|
||||
|
||||
_, kwargs = mock_orig.call_args
|
||||
assert kwargs["context"].get("is_logged_in") is True
|
||||
|
||||
def test_is_logged_in_false_when_no_session_user(self):
|
||||
"""is_logged_in should be False when auth is enabled and no session user."""
|
||||
from app.views.base import template_response_with_version
|
||||
|
||||
with (
|
||||
patch("app.views.base.original_template_response") as mock_orig,
|
||||
patch("app.views.base.settings") as mock_settings,
|
||||
):
|
||||
mock_orig.return_value = "response"
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.multi_user_enabled = True
|
||||
mock_settings.version = "0.0.0"
|
||||
mock_settings.release_name = None
|
||||
mock_settings.ui_default_color_scheme = "system"
|
||||
|
||||
req = MagicMock()
|
||||
req.session.get.return_value = None
|
||||
del req.state.csrf_token
|
||||
|
||||
template_response_with_version(
|
||||
"template.html",
|
||||
context={"request": req},
|
||||
)
|
||||
|
||||
_, kwargs = mock_orig.call_args
|
||||
assert kwargs["context"].get("is_logged_in") is False
|
||||
|
||||
def test_is_logged_in_false_when_request_has_no_session(self):
|
||||
"""is_logged_in should default to False when request has no session attribute."""
|
||||
from app.views.base import template_response_with_version
|
||||
|
||||
with (
|
||||
patch("app.views.base.original_template_response") as mock_orig,
|
||||
patch("app.views.base.settings") as mock_settings,
|
||||
):
|
||||
mock_orig.return_value = "response"
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.multi_user_enabled = False
|
||||
mock_settings.version = "0.0.0"
|
||||
mock_settings.release_name = None
|
||||
mock_settings.ui_default_color_scheme = "system"
|
||||
|
||||
# Use a simple object without session attribute
|
||||
class NoSessionRequest:
|
||||
class state:
|
||||
pass # no csrf_token
|
||||
|
||||
template_response_with_version(
|
||||
"template.html",
|
||||
context={"request": NoSessionRequest()},
|
||||
)
|
||||
|
||||
_, kwargs = mock_orig.call_args
|
||||
assert kwargs["context"].get("is_logged_in") is False
|
||||
|
||||
def test_multi_user_enabled_injected(self):
|
||||
"""multi_user_enabled should be injected into every template context."""
|
||||
from app.views.base import template_response_with_version
|
||||
|
||||
with (
|
||||
patch("app.views.base.original_template_response") as mock_orig,
|
||||
patch("app.views.base.settings") as mock_settings,
|
||||
):
|
||||
mock_orig.return_value = "response"
|
||||
mock_settings.auth_enabled = False
|
||||
mock_settings.multi_user_enabled = True
|
||||
mock_settings.version = "0.0.0"
|
||||
mock_settings.release_name = None
|
||||
mock_settings.ui_default_color_scheme = "system"
|
||||
|
||||
template_response_with_version(
|
||||
"template.html",
|
||||
context={"title": "no request"},
|
||||
)
|
||||
|
||||
_, kwargs = mock_orig.call_args
|
||||
assert kwargs["context"].get("multi_user_enabled") is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# app/views/general.py – error branches
|
||||
|
||||
Reference in New Issue
Block a user