diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index 9dd386f..5d13a29 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -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() diff --git a/src/main/java/org/operaton/fitpub/controller/AuthController.java b/src/main/java/org/operaton/fitpub/controller/AuthController.java index 8d6497e..ead4950 100644 --- a/src/main/java/org/operaton/fitpub/controller/AuthController.java +++ b/src/main/java/org/operaton/fitpub/controller/AuthController.java @@ -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 register(@Valid @RequestBody RegisterRequest request) { + public ResponseEntity 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 login(@Valid @RequestBody LoginRequest request) { + public ResponseEntity 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 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). */ diff --git a/src/main/java/org/operaton/fitpub/security/CustomAuthenticationEntryPoint.java b/src/main/java/org/operaton/fitpub/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..dd53495 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/security/CustomAuthenticationEntryPoint.java @@ -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); + } +} diff --git a/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java b/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java index 318a1c1..4d570f6 100644 --- a/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java @@ -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; } } diff --git a/src/main/java/org/operaton/fitpub/service/WeatherService.java b/src/main/java/org/operaton/fitpub/service/WeatherService.java index ced9421..e71d761 100644 --- a/src/main/java/org/operaton/fitpub/service/WeatherService.java +++ b/src/main/java/org/operaton/fitpub/service/WeatherService.java @@ -51,40 +51,61 @@ public class WeatherService { */ @Transactional public Optional 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; } } diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js index dd774c6..0a94a0a 100644 --- a/src/main/resources/static/js/auth.js +++ b/src/main/resources/static/js/auth.js @@ -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} 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) { diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 12fc385..70774f2 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -37,6 +37,20 @@ FitPub + + +