Quality of Life, Security

This commit is contained in:
Tim Zöller 2026-01-03 22:30:04 +01:00
parent 5455f8cd36
commit 0fe0810f51
7 changed files with 372 additions and 126 deletions

View file

@ -36,6 +36,7 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
private final org.operaton.fitpub.security.CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
/**
* Configures the security filter chain.
@ -48,6 +49,9 @@ public class SecurityConfig {
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(customAuthenticationEntryPoint)
)
.authorizeHttpRequests(auth -> auth
// Public endpoints - Static resources
.requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll()
@ -56,13 +60,19 @@ public class SecurityConfig {
.requestMatchers("/error").permitAll()
// Public endpoints - Web UI pages
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**").permitAll()
.requestMatchers("/discover").permitAll() // User discovery page
.requestMatchers("/notifications").permitAll() // Auth checked client-side
.requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side
.requestMatchers("/heatmap").permitAll() // Auth checked client-side
.requestMatchers("/batch-upload").permitAll() // Batch import page (Auth checked client-side)
// Protected view pages - require authentication
.requestMatchers("/activities", "/activities/upload").authenticated()
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
.requestMatchers("/notifications").authenticated()
.requestMatchers("/analytics", "/analytics/**").authenticated()
.requestMatchers("/heatmap").authenticated()
.requestMatchers("/batch-upload").authenticated()
// Public - Individual activity view pages (for public activities)
.requestMatchers("/activities/*").permitAll()
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()

View file

@ -1,5 +1,7 @@
package org.operaton.fitpub.controller;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -32,10 +34,13 @@ public class AuthController {
* Register a new user account.
*
* @param request Registration details
* @param response HTTP response for setting cookies
* @return Authentication response with JWT token
*/
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
public ResponseEntity<AuthResponse> register(
@Valid @RequestBody RegisterRequest request,
HttpServletResponse response) {
// Check if registration is enabled
if (!registrationEnabled) {
log.warn("Registration attempt blocked - registration is disabled");
@ -46,8 +51,12 @@ public class AuthController {
log.info("Registration request received for username: {}", request.getUsername());
try {
AuthResponse response = userService.registerUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
AuthResponse authResponse = userService.registerUser(request);
// Set JWT as httpOnly cookie
setJwtCookie(response, authResponse.getToken());
return ResponseEntity.status(HttpStatus.CREATED).body(authResponse);
} catch (IllegalArgumentException e) {
log.warn("Registration failed: {}", e.getMessage());
throw e;
@ -68,21 +77,65 @@ public class AuthController {
* Authenticate user and generate JWT token.
*
* @param request Login credentials
* @param response HTTP response for setting cookies
* @return Authentication response with JWT token
*/
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request,
HttpServletResponse response) {
log.info("Login request received for: {}", request.getUsernameOrEmail());
try {
AuthResponse response = userService.login(request);
return ResponseEntity.ok(response);
AuthResponse authResponse = userService.login(request);
// Set JWT as httpOnly cookie
setJwtCookie(response, authResponse.getToken());
return ResponseEntity.ok(authResponse);
} catch (BadCredentialsException e) {
log.warn("Login failed: {}", e.getMessage());
throw e;
}
}
/**
* Logout user by clearing the JWT cookie.
*
* @param response HTTP response for clearing cookies
* @return Success response
*/
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletResponse response) {
log.info("Logout request received");
// Clear the JWT cookie
Cookie cookie = new Cookie("JWT_TOKEN", null);
cookie.setHttpOnly(true);
cookie.setSecure(false); // Set to true in production with HTTPS
cookie.setPath("/");
cookie.setMaxAge(0); // Delete cookie
response.addCookie(cookie);
return ResponseEntity.ok().build();
}
/**
* Helper method to set JWT as httpOnly cookie.
*
* @param response HTTP response
* @param token JWT token
*/
private void setJwtCookie(HttpServletResponse response, String token) {
Cookie cookie = new Cookie("JWT_TOKEN", token);
cookie.setHttpOnly(true); // Prevent JavaScript access
cookie.setSecure(false); // Set to true in production with HTTPS
cookie.setPath("/");
cookie.setMaxAge(24 * 60 * 60); // 24 hours (same as JWT expiration)
response.addCookie(cookie);
log.debug("JWT cookie set");
}
/**
* Exception handler for IllegalArgumentException (e.g., duplicate username/email).
*/

View file

@ -0,0 +1,53 @@
package org.operaton.fitpub.security;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* Custom authentication entry point that handles unauthenticated requests.
* - Redirects to /login for HTML page requests (browser navigation)
* - Returns 403 Forbidden for API requests (AJAX, fetch calls)
*/
@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
String requestUri = request.getRequestURI();
String accept = request.getHeader("Accept");
log.debug("Unauthenticated request to {} with Accept: {}", requestUri, accept);
// API requests should get 403 Forbidden
if (requestUri.startsWith("/api/")) {
log.debug("API request - returning 403 Forbidden");
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
return;
}
// Check if this is a JSON/API request based on Accept header
if (accept != null && (accept.contains("application/json") ||
accept.contains("application/activity+json") ||
accept.contains("application/ld+json"))) {
log.debug("JSON API request - returning 403 Forbidden");
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
return;
}
// HTML page requests should redirect to login
log.debug("HTML page request - redirecting to /login");
String redirectUrl = "/login?redirect=" + requestUri;
response.sendRedirect(redirectUrl);
}
}

View file

@ -2,6 +2,7 @@ package org.operaton.fitpub.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@ -15,6 +16,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
/**
* JWT authentication filter that validates JWT tokens on each request.
@ -61,13 +63,34 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
/**
* Extracts the JWT token from the Authorization header.
* Extracts the JWT token from cookies or Authorization header.
* Priority: 1) Cookie, 2) Authorization header
*
* @param request the HTTP request
* @return the JWT token or null if not found
*/
private String getJwtFromRequest(HttpServletRequest request) {
// First, try to get JWT from cookie
Cookie[] cookies = request.getCookies();
if (cookies != null) {
String tokenFromCookie = Arrays.stream(cookies)
.filter(cookie -> "JWT_TOKEN".equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
if (tokenFromCookie != null && !tokenFromCookie.isEmpty()) {
log.debug("JWT token found in cookie");
return tokenFromCookie;
}
}
// Fallback to Authorization header (for API clients)
String bearerToken = request.getHeader("Authorization");
return tokenProvider.resolveToken(bearerToken);
String tokenFromHeader = tokenProvider.resolveToken(bearerToken);
if (tokenFromHeader != null) {
log.debug("JWT token found in Authorization header");
}
return tokenFromHeader;
}
}

View file

@ -51,40 +51,61 @@ public class WeatherService {
*/
@Transactional
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
if (!weatherEnabled || apiKey == null || apiKey.isBlank()) {
log.debug("Weather fetching is disabled or API key is not configured");
log.info("=== Weather fetch requested for activity {} ===", activity.getId());
log.info("Weather enabled: {}, API key configured: {}", weatherEnabled, (apiKey != null && !apiKey.isBlank()));
if (!weatherEnabled) {
log.warn("Weather fetching is DISABLED in configuration (fitpub.weather.enabled=false)");
return Optional.empty();
}
if (apiKey == null || apiKey.isBlank()) {
log.error("Weather API key is NOT CONFIGURED (fitpub.weather.api-key is empty)");
return Optional.empty();
}
log.debug("Weather API key present (length: {} chars, starts with: {}...)",
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
// Check if weather data already exists
if (weatherDataRepository.existsByActivityId(activity.getId())) {
log.debug("Weather data already exists for activity {}", activity.getId());
log.info("Weather data already exists for activity {}, returning cached data", activity.getId());
return weatherDataRepository.findByActivityId(activity.getId());
}
// Extract start location from track
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
log.debug("No track points available for activity {}", activity.getId());
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
return Optional.empty();
}
log.debug("Track points JSON length: {} chars", activity.getTrackPointsJson().length());
try {
// Get first track point for location
JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson());
log.debug("Parsed track points, is array: {}, size: {}",
trackPoints.isArray(), trackPoints.isArray() ? trackPoints.size() : "N/A");
if (!trackPoints.isArray() || trackPoints.isEmpty()) {
log.warn("Track points is not an array or is empty for activity {}", activity.getId());
return Optional.empty();
}
JsonNode firstPoint = trackPoints.get(0);
log.debug("First track point fields: {}", firstPoint.fieldNames().hasNext() ?
String.join(", ", () -> firstPoint.fieldNames()) : "none");
// Check if lat/lon fields exist
if (!firstPoint.has("lat") || !firstPoint.has("lon")) {
log.debug("First track point missing lat/lon for activity {}", activity.getId());
log.error("First track point missing lat/lon fields for activity {}. Available fields: {}",
activity.getId(), String.join(", ", () -> firstPoint.fieldNames()));
return Optional.empty();
}
double lat = firstPoint.get("lat").asDouble();
double lon = firstPoint.get("lon").asDouble();
log.info("Extracted location: lat={}, lon={}", lat, lon);
// Check if activity is recent (within 5 days) - use current weather API
// Otherwise use historical data API (requires paid plan)
@ -92,22 +113,29 @@ public class WeatherService {
long currentTimestamp = Instant.now().getEpochSecond();
long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference);
WeatherData weatherData;
if (daysDifference <= 5) {
log.info("Activity is recent (within 5 days), fetching current weather");
weatherData = fetchCurrentWeather(lat, lon, activity.getId());
} else {
log.debug("Activity is older than 5 days, historical weather data requires paid API plan");
// For historical data, we would use the Time Machine API
// weatherData = fetchHistoricalWeather(lat, lon, activityTimestamp, activity.getId());
log.warn("Activity is {} days old (>5 days), historical weather data requires paid API plan. Skipping.", daysDifference);
return Optional.empty();
}
if (weatherData != null) {
return Optional.of(weatherDataRepository.save(weatherData));
log.info("Successfully fetched and parsed weather data, saving to database");
WeatherData saved = weatherDataRepository.save(weatherData);
log.info("Weather data saved with ID: {}", saved.getId());
return Optional.of(saved);
} else {
log.error("Weather data fetch returned null");
}
} catch (Exception e) {
log.error("Error fetching weather data for activity {}: {}", activity.getId(), e.getMessage());
log.error("EXCEPTION while fetching weather data for activity {}: {}",
activity.getId(), e.getMessage(), e);
}
return Optional.empty();
@ -121,17 +149,50 @@ public class WeatherService {
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
log.debug("Fetching current weather from: {}", url.replace(apiKey, "***"));
String maskedUrl = url.replace(apiKey, "***API_KEY***");
log.info("Making API request to OpenWeatherMap: {}", maskedUrl);
log.debug("API URL: {}", OPENWEATHERMAP_API_URL);
log.debug("Coordinates: lat={}, lon={}", lat, lon);
long startTime = System.currentTimeMillis();
String response = restTemplate.getForObject(URI.create(url), String.class);
long duration = System.currentTimeMillis() - startTime;
log.info("API request completed in {}ms", duration);
if (response == null) {
log.error("API response is NULL - no data returned from OpenWeatherMap");
return null;
}
return parseWeatherResponse(response, activityId);
log.debug("API response length: {} chars", response.length());
log.debug("API response preview: {}", response.length() > 200 ? response.substring(0, 200) + "..." : response);
WeatherData weatherData = parseWeatherResponse(response, activityId);
if (weatherData == null) {
log.error("Failed to parse weather response");
} else {
log.info("Successfully parsed weather data: temp={}°C, condition={}",
weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition());
}
return weatherData;
} catch (org.springframework.web.client.HttpClientErrorException e) {
log.error("HTTP CLIENT ERROR from OpenWeatherMap API: Status={}, Body={}",
e.getStatusCode(), e.getResponseBodyAsString(), e);
return null;
} catch (org.springframework.web.client.HttpServerErrorException e) {
log.error("HTTP SERVER ERROR from OpenWeatherMap API: Status={}, Body={}",
e.getStatusCode(), e.getResponseBodyAsString(), e);
return null;
} catch (org.springframework.web.client.RestClientException e) {
log.error("REST CLIENT EXCEPTION calling OpenWeatherMap API: {}", e.getMessage(), e);
return null;
} catch (Exception e) {
log.error("Error fetching current weather: {}", e.getMessage());
log.error("UNEXPECTED EXCEPTION fetching current weather: {} - {}",
e.getClass().getSimpleName(), e.getMessage(), e);
return null;
}
}

View file

@ -6,25 +6,30 @@
const FitPubAuth = {
/**
* Get the stored JWT token
* @returns {string|null} JWT token or null if not found
* Note: With httpOnly cookies, the token is not accessible via JavaScript
* This method is kept for backward compatibility but returns null
* @returns {null} Always returns null (token is in httpOnly cookie)
*/
getToken: function() {
return localStorage.getItem('jwtToken');
// Token is now in httpOnly cookie, not accessible to JavaScript
return null;
},
/**
* Store JWT token
* @param {string} token - JWT token to store
* Note: Not used anymore - token is stored in httpOnly cookie by server
* @param {string} token - JWT token (ignored, kept for compatibility)
*/
setToken: function(token) {
localStorage.setItem('jwtToken', token);
// Token is automatically stored in httpOnly cookie by server
// No action needed
},
/**
* Remove stored JWT token
* Remove stored JWT token and username
*/
removeToken: function() {
localStorage.removeItem('jwtToken');
// Cookie is cleared by server on logout
localStorage.removeItem('username');
},
@ -46,31 +51,15 @@ const FitPubAuth = {
/**
* Check if user is authenticated
* With httpOnly cookies, we check if username is stored (set on login)
* The actual authentication is verified server-side on each request
* @returns {boolean} True if authenticated, false otherwise
*/
isAuthenticated: function() {
const token = this.getToken();
if (!token) {
return false;
}
// Check if token is expired
try {
const payload = this.parseJwt(token);
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
// Token expired, remove it
this.removeToken();
return false;
}
return true;
} catch (e) {
console.error('Error parsing JWT:', e);
this.removeToken();
return false;
}
// With httpOnly cookies, we can't access the token
// We check if username exists in localStorage (set on successful login)
const username = this.getUsername();
return username != null && username !== '';
},
/**
@ -121,28 +110,36 @@ const FitPubAuth = {
/**
* Logout user
*/
logout: function() {
logout: async function() {
try {
// Call server logout endpoint to clear the httpOnly cookie
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include' // Important: send cookies
});
} catch (error) {
console.error('Logout request failed:', error);
// Continue with logout even if server request fails
}
// Clear client-side storage
this.removeToken();
// Redirect to login page
window.location.href = '/login';
},
/**
* Make an authenticated API request
* With httpOnly cookies, authentication is automatic via cookies
* @param {string} url - API endpoint URL
* @param {object} options - Fetch options
* @returns {Promise<Response>} Fetch response
*/
authenticatedFetch: async function(url, options = {}) {
const token = this.getToken();
if (!token) {
throw new Error('No authentication token found');
}
// Add Authorization header
// Prepare headers
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`,
...options.headers
};
// If body is an object, set Content-Type to JSON
@ -151,13 +148,15 @@ const FitPubAuth = {
options.body = JSON.stringify(options.body);
}
// Make request with credentials to send httpOnly cookie
const response = await fetch(url, {
...options,
headers
headers,
credentials: 'include' // Important: send cookies
});
// If unauthorized, redirect to login
if (response.status === 401) {
// If unauthorized, clear local state and redirect to login
if (response.status === 401 || response.status === 403) {
this.removeToken();
window.location.href = '/login';
throw new Error('Authentication failed');
@ -214,12 +213,12 @@ const FitPubAuth = {
const authUserMenu = document.getElementById('authUserMenu');
const guestMenu = document.getElementById('guestMenu');
const usernameDisplay = document.getElementById('usernameDisplay');
const discoverLink = document.getElementById('discoverLink');
const myActivitiesLink = document.getElementById('myActivitiesLink');
const uploadLink = document.getElementById('uploadLink');
const batchUploadLink = document.getElementById('batchUploadLink');
const analyticsLink = document.getElementById('analyticsLink');
const heatmapLink = document.getElementById('heatmapLink');
const uploadDropdown = document.getElementById('uploadDropdown');
const analyticsDropdown = document.getElementById('analyticsDropdown');
const notificationsBell = document.getElementById('notificationsBell');
const notificationsBellMobile = document.getElementById('notificationsBellMobile');
if (this.isAuthenticated()) {
// Show authenticated menu, hide guest menu
@ -231,30 +230,31 @@ const FitPubAuth = {
}
// Show authenticated navigation links
if (discoverLink) {
discoverLink.style.display = '';
discoverLink.parentElement.style.display = '';
}
if (myActivitiesLink) {
myActivitiesLink.style.display = '';
myActivitiesLink.parentElement.style.display = '';
}
if (uploadLink) {
uploadLink.style.display = '';
uploadLink.parentElement.style.display = '';
// Show dropdown menus
if (uploadDropdown) {
uploadDropdown.classList.remove('d-none');
}
if (batchUploadLink) {
batchUploadLink.style.display = '';
batchUploadLink.parentElement.style.display = '';
}
if (analyticsLink) {
analyticsLink.style.display = '';
analyticsLink.parentElement.style.display = '';
}
if (heatmapLink) {
heatmapLink.style.display = '';
heatmapLink.parentElement.style.display = '';
if (analyticsDropdown) {
analyticsDropdown.classList.remove('d-none');
}
// Show notifications bell
// Show notifications bell (desktop: visible on lg+, mobile: visible below lg)
if (notificationsBell) {
notificationsBell.classList.remove('d-none');
notificationsBell.classList.add('d-lg-block'); // Visible on lg+, hidden below
}
if (notificationsBellMobile) {
notificationsBellMobile.classList.remove('d-none', 'd-lg-none');
notificationsBellMobile.classList.add('d-block', 'd-lg-none'); // Visible below lg, hidden on lg+
}
// Display username
@ -270,33 +270,37 @@ const FitPubAuth = {
if (authUserMenu) {
authUserMenu.classList.add('d-none');
}
// Hide notification bells completely
if (notificationsBell) {
notificationsBell.classList.remove('d-lg-block', 'd-block');
notificationsBell.classList.add('d-none');
}
if (notificationsBellMobile) {
notificationsBellMobile.classList.remove('d-block', 'd-lg-none');
notificationsBellMobile.classList.add('d-none');
}
if (guestMenu) {
guestMenu.style.display = '';
}
// Hide authenticated navigation links
if (discoverLink) {
discoverLink.style.display = 'none';
discoverLink.parentElement.style.display = 'none';
}
if (myActivitiesLink) {
myActivitiesLink.style.display = 'none';
myActivitiesLink.parentElement.style.display = 'none';
}
if (uploadLink) {
uploadLink.style.display = 'none';
uploadLink.parentElement.style.display = 'none';
// Hide dropdown menus
if (uploadDropdown) {
uploadDropdown.classList.add('d-none');
}
if (batchUploadLink) {
batchUploadLink.style.display = 'none';
batchUploadLink.parentElement.style.display = 'none';
}
if (analyticsLink) {
analyticsLink.style.display = 'none';
analyticsLink.parentElement.style.display = 'none';
}
if (heatmapLink) {
heatmapLink.style.display = 'none';
heatmapLink.parentElement.style.display = 'none';
if (analyticsDropdown) {
analyticsDropdown.classList.add('d-none');
}
}
},
@ -400,14 +404,22 @@ const FitPubAuth = {
const response = await this.authenticatedFetch('/api/notifications/unread/count');
if (response.ok) {
const data = await response.json();
const badge = document.getElementById('navNotificationCount');
if (badge) {
if (data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
const badgeDesktop = document.getElementById('navNotificationCount');
const badgeMobile = document.getElementById('navNotificationCountMobile');
const displayText = data.count > 99 ? '99+' : data.count;
const shouldDisplay = data.count > 0;
// Update desktop badge
if (badgeDesktop) {
badgeDesktop.textContent = displayText;
badgeDesktop.style.display = shouldDisplay ? 'inline-block' : 'none';
}
// Update mobile badge
if (badgeMobile) {
badgeMobile.textContent = displayText;
badgeMobile.style.display = shouldDisplay ? 'inline-block' : 'none';
}
}
} catch (error) {

View file

@ -37,6 +37,20 @@
<i class="bi bi-activity"></i> FitPub
</a>
<!-- Notifications bell - always visible on mobile, next to hamburger -->
<ul class="navbar-nav d-flex flex-row order-lg-1 ms-auto">
<li class="nav-item d-none me-2" id="notificationsBellMobile">
<a class="nav-link position-relative" th:href="@{/notifications}" title="Notifications">
<i class="bi bi-bell"></i>
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
id="navNotificationCountMobile"
style="display: none; font-size: 0.6rem;">
0
</span>
</a>
</li>
</ul>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
@ -56,7 +70,7 @@
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/discover}">
<a class="nav-link" th:href="@{/discover}" id="discoverLink" style="display: none;">
<i class="bi bi-people"></i> Discover
</a>
</li>
@ -65,32 +79,52 @@
<i class="bi bi-list-task"></i> My Activities
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/activities/upload}" id="uploadLink" style="display: none;">
<!-- Upload dropdown menu -->
<li class="nav-item dropdown d-none" id="uploadDropdown">
<a class="nav-link dropdown-toggle" href="#" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-cloud-upload"></i> Upload
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" th:href="@{/activities/upload}">
<i class="bi bi-file-earmark-arrow-up"></i> Single Activity
</a>
</li>
<li>
<a class="dropdown-item" th:href="@{/batch-upload}">
<i class="bi bi-file-earmark-zip"></i> Batch Import
</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/batch-upload}" id="batchUploadLink" style="display: none;">
<i class="bi bi-file-earmark-zip"></i> Batch Import
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/analytics}" id="analyticsLink" style="display: none;">
<!-- Analytics dropdown menu -->
<li class="nav-item dropdown d-none" id="analyticsDropdown">
<a class="nav-link dropdown-toggle" href="#" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-graph-up"></i> Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/heatmap}" id="heatmapLink" style="display: none;">
<i class="bi bi-map"></i> Heatmap
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" th:href="@{/analytics}">
<i class="bi bi-bar-chart"></i> Dashboard
</a>
</li>
<li>
<a class="dropdown-item" th:href="@{/heatmap}">
<i class="bi bi-map"></i> Heatmap
</a>
</li>
</ul>
</li>
</ul>
<!-- Right side navigation -->
<ul class="navbar-nav">
<!-- Notifications bell (hidden by default, shown by JS if JWT exists) -->
<li class="nav-item d-none" id="notificationsBell">
<!-- Notifications bell (hidden on mobile, shown on desktop) -->
<li class="nav-item d-none d-lg-block" id="notificationsBell">
<a class="nav-link position-relative" th:href="@{/notifications}" title="Notifications">
<i class="bi bi-bell"></i>
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"