Quality of Life, Security
This commit is contained in:
parent
5455f8cd36
commit
0fe0810f51
7 changed files with 372 additions and 126 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue