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

View file

@ -1,5 +1,7 @@
package org.operaton.fitpub.controller; package org.operaton.fitpub.controller;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -32,10 +34,13 @@ public class AuthController {
* Register a new user account. * Register a new user account.
* *
* @param request Registration details * @param request Registration details
* @param response HTTP response for setting cookies
* @return Authentication response with JWT token * @return Authentication response with JWT token
*/ */
@PostMapping("/register") @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 // Check if registration is enabled
if (!registrationEnabled) { if (!registrationEnabled) {
log.warn("Registration attempt blocked - registration is disabled"); log.warn("Registration attempt blocked - registration is disabled");
@ -46,8 +51,12 @@ public class AuthController {
log.info("Registration request received for username: {}", request.getUsername()); log.info("Registration request received for username: {}", request.getUsername());
try { try {
AuthResponse response = userService.registerUser(request); AuthResponse authResponse = userService.registerUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
// Set JWT as httpOnly cookie
setJwtCookie(response, authResponse.getToken());
return ResponseEntity.status(HttpStatus.CREATED).body(authResponse);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.warn("Registration failed: {}", e.getMessage()); log.warn("Registration failed: {}", e.getMessage());
throw e; throw e;
@ -68,21 +77,65 @@ public class AuthController {
* Authenticate user and generate JWT token. * Authenticate user and generate JWT token.
* *
* @param request Login credentials * @param request Login credentials
* @param response HTTP response for setting cookies
* @return Authentication response with JWT token * @return Authentication response with JWT token
*/ */
@PostMapping("/login") @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()); log.info("Login request received for: {}", request.getUsernameOrEmail());
try { try {
AuthResponse response = userService.login(request); AuthResponse authResponse = userService.login(request);
return ResponseEntity.ok(response);
// Set JWT as httpOnly cookie
setJwtCookie(response, authResponse.getToken());
return ResponseEntity.ok(authResponse);
} catch (BadCredentialsException e) { } catch (BadCredentialsException e) {
log.warn("Login failed: {}", e.getMessage()); log.warn("Login failed: {}", e.getMessage());
throw e; 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). * 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.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -15,6 +16,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays;
/** /**
* JWT authentication filter that validates JWT tokens on each request. * 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 * @param request the HTTP request
* @return the JWT token or null if not found * @return the JWT token or null if not found
*/ */
private String getJwtFromRequest(HttpServletRequest request) { 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"); 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 @Transactional
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) { public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
if (!weatherEnabled || apiKey == null || apiKey.isBlank()) { log.info("=== Weather fetch requested for activity {} ===", activity.getId());
log.debug("Weather fetching is disabled or API key is not configured"); 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(); 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 // Check if weather data already exists
if (weatherDataRepository.existsByActivityId(activity.getId())) { 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()); return weatherDataRepository.findByActivityId(activity.getId());
} }
// Extract start location from track // Extract start location from track
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) { 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(); return Optional.empty();
} }
log.debug("Track points JSON length: {} chars", activity.getTrackPointsJson().length());
try { try {
// Get first track point for location // Get first track point for location
JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson()); 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()) { if (!trackPoints.isArray() || trackPoints.isEmpty()) {
log.warn("Track points is not an array or is empty for activity {}", activity.getId());
return Optional.empty(); return Optional.empty();
} }
JsonNode firstPoint = trackPoints.get(0); 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 // Check if lat/lon fields exist
if (!firstPoint.has("lat") || !firstPoint.has("lon")) { 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(); return Optional.empty();
} }
double lat = firstPoint.get("lat").asDouble(); double lat = firstPoint.get("lat").asDouble();
double lon = firstPoint.get("lon").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 // Check if activity is recent (within 5 days) - use current weather API
// Otherwise use historical data API (requires paid plan) // Otherwise use historical data API (requires paid plan)
@ -92,22 +113,29 @@ public class WeatherService {
long currentTimestamp = Instant.now().getEpochSecond(); long currentTimestamp = Instant.now().getEpochSecond();
long daysDifference = (currentTimestamp - activityTimestamp) / 86400; long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference);
WeatherData weatherData; WeatherData weatherData;
if (daysDifference <= 5) { if (daysDifference <= 5) {
log.info("Activity is recent (within 5 days), fetching current weather");
weatherData = fetchCurrentWeather(lat, lon, activity.getId()); weatherData = fetchCurrentWeather(lat, lon, activity.getId());
} else { } else {
log.debug("Activity is older than 5 days, historical weather data requires paid API plan"); log.warn("Activity is {} days old (>5 days), historical weather data requires paid API plan. Skipping.", daysDifference);
// For historical data, we would use the Time Machine API
// weatherData = fetchHistoricalWeather(lat, lon, activityTimestamp, activity.getId());
return Optional.empty(); return Optional.empty();
} }
if (weatherData != null) { 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) { } 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(); return Optional.empty();
@ -121,17 +149,50 @@ public class WeatherService {
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric", String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
OPENWEATHERMAP_API_URL, lat, lon, apiKey); 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); 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) { if (response == null) {
log.error("API response is NULL - no data returned from OpenWeatherMap");
return null; 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) { } 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; return null;
} }
} }

View file

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

View file

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