FEAT: Enhance authentication session management with configurable cookie domain and improved token refresh logic
This commit is contained in:
parent
3a83831a20
commit
d436367ee6
|
|
@ -26,3 +26,9 @@ NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
|||
NUXT_OIDC_TOKEN_KEY=base64_encoded_32_byte_key
|
||||
NUXT_OIDC_SESSION_SECRET=48_character_random_string_for_session_security
|
||||
NUXT_OIDC_AUTH_SESSION_SECRET=48_character_random_string_for_auth_session
|
||||
|
||||
# Cookie Configuration
|
||||
COOKIE_DOMAIN=.portnimara.dev
|
||||
|
||||
# Keycloak Configuration (used by custom auth)
|
||||
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||
|
|
|
|||
|
|
@ -655,6 +655,7 @@ import {
|
|||
Deposit10PercentStatusFlow,
|
||||
ContractStatusFlow,
|
||||
} from "@/utils/types";
|
||||
import { formatDate } from "@/utils/dateUtils";
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
|
|
@ -1138,54 +1139,6 @@ const updateBerthRecommendations = async (newRecommendations: number[]) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "";
|
||||
|
||||
try {
|
||||
let date: Date;
|
||||
|
||||
// Check if it's an ISO date string (e.g., "2025-06-09T22:58:47.731Z")
|
||||
if (dateString.includes('T') || dateString.includes('Z')) {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
// Handle DD-MM-YYYY format
|
||||
else if (dateString.match(/^\d{2}-\d{2}-\d{4}$/)) {
|
||||
const [day, month, year] = dateString.split("-");
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
// Handle YYYY-MM-DD format
|
||||
else if (dateString.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
// Fallback to direct parsing
|
||||
else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// Format date in DD/MM/YYYY HH:mm format
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Include time if it's not midnight
|
||||
if (hours !== '00' || minutes !== '00') {
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
return `${day}/${month}/${year}`;
|
||||
} catch (error) {
|
||||
console.error('Date formatting error:', error, dateString);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Get color for sales level - matching InterestSalesBadge.vue
|
||||
const getSalesLevelColor = (level: string) => {
|
||||
|
|
|
|||
|
|
@ -16,14 +16,20 @@ export const useCustomAuth = () => {
|
|||
const authenticated = ref(false)
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const retryCount = ref(0)
|
||||
const maxRetries = 3
|
||||
|
||||
// Check authentication status
|
||||
const checkAuth = async () => {
|
||||
// Check authentication status with retry logic
|
||||
const checkAuth = async (skipRetry = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await $fetch<AuthState>('/api/auth/session')
|
||||
const data = await $fetch<AuthState>('/api/auth/session', {
|
||||
retry: skipRetry ? 0 : 2,
|
||||
retryDelay: 1000
|
||||
})
|
||||
user.value = data.user
|
||||
authenticated.value = data.authenticated
|
||||
retryCount.value = 0 // Reset retry count on success
|
||||
|
||||
console.log('[CUSTOM_AUTH] Session check result:', {
|
||||
authenticated: data.authenticated,
|
||||
|
|
@ -31,6 +37,17 @@ export const useCustomAuth = () => {
|
|||
})
|
||||
} catch (error) {
|
||||
console.error('[CUSTOM_AUTH] Session check failed:', error)
|
||||
|
||||
// If it's a network error and we haven't exceeded retry limit, try refresh
|
||||
if (!skipRetry && retryCount.value < maxRetries && (error as any)?.status >= 500) {
|
||||
retryCount.value++
|
||||
console.log(`[CUSTOM_AUTH] Retrying session check (${retryCount.value}/${maxRetries})...`)
|
||||
|
||||
// Wait a bit before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount.value))
|
||||
return checkAuth(false)
|
||||
}
|
||||
|
||||
user.value = null
|
||||
authenticated.value = false
|
||||
} finally {
|
||||
|
|
@ -38,7 +55,7 @@ export const useCustomAuth = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
// Refresh token with better error handling
|
||||
const refreshToken = async () => {
|
||||
if (refreshing.value) return false
|
||||
|
||||
|
|
@ -46,23 +63,34 @@ export const useCustomAuth = () => {
|
|||
refreshing.value = true
|
||||
console.log('[CUSTOM_AUTH] Attempting token refresh...')
|
||||
|
||||
const response = await $fetch<{ success: boolean }>('/api/auth/refresh', {
|
||||
method: 'POST'
|
||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
retry: 2,
|
||||
retryDelay: 1000
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('[CUSTOM_AUTH] Token refresh successful')
|
||||
await checkAuth() // Re-check auth state after refresh
|
||||
await checkAuth(true) // Re-check auth state after refresh, skip retry to avoid loops
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('[CUSTOM_AUTH] Token refresh failed:', error)
|
||||
// Clear auth state on refresh failure
|
||||
|
||||
// Check if it's a 401 (invalid refresh token) vs other errors
|
||||
if ((error as any)?.status === 401) {
|
||||
console.log('[CUSTOM_AUTH] Refresh token invalid, clearing auth state')
|
||||
user.value = null
|
||||
authenticated.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
// For other errors (network issues, 502, etc.), don't clear auth state immediately
|
||||
// The auto-refresh plugin will handle retries
|
||||
console.log('[CUSTOM_AUTH] Network error during refresh, keeping auth state')
|
||||
return false
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
# Authentication Session Fixes Summary
|
||||
|
||||
## Problem Description
|
||||
|
||||
Users were experiencing frequent logouts (every 5 minutes) and 502 errors when trying to re-login through Keycloak SSO. The authentication system was not properly managing session lifetimes and token refresh.
|
||||
|
||||
## Root Causes Identified
|
||||
|
||||
1. **Short Session Duration**: Cookie lifetime was tied to access token expiry (5 minutes) instead of SSO session duration
|
||||
2. **No Automatic Token Refresh**: Tokens expired without automatic refresh, forcing manual re-login
|
||||
3. **Cookie Domain Issues**: Hardcoded cookie domain causing potential CORS issues
|
||||
4. **Poor Error Handling**: 502 errors weren't handled gracefully with retry logic
|
||||
|
||||
## Solutions Implemented
|
||||
|
||||
### 1. Fixed Session Cookie Duration
|
||||
|
||||
**Files Modified:**
|
||||
- `server/api/auth/keycloak/callback.ts`
|
||||
- `server/api/auth/refresh.ts`
|
||||
- `server/api/auth/session.ts`
|
||||
|
||||
**Changes:**
|
||||
- Changed cookie `maxAge` from access token lifetime (5 minutes) to SSO session duration (8 hours)
|
||||
- Made cookie domain configurable via `COOKIE_DOMAIN` environment variable
|
||||
- Separated access token lifetime from session cookie lifetime
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
maxAge: tokenResponse.expires_in, // 5 minutes
|
||||
|
||||
// After
|
||||
const sessionDuration = 8 * 60 * 60; // 8 hours in seconds
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
maxAge: sessionDuration,
|
||||
domain: cookieDomain,
|
||||
```
|
||||
|
||||
### 2. Implemented Auto-Refresh System
|
||||
|
||||
**New File:**
|
||||
- `plugins/01.auth-refresh.client.ts`
|
||||
|
||||
**Features:**
|
||||
- Automatically refreshes tokens 2 minutes before expiry
|
||||
- Monitors session expiration client-side
|
||||
- Handles tab visibility changes to refresh when user returns
|
||||
- Graceful fallback to login page on refresh failure
|
||||
|
||||
**Key Functions:**
|
||||
- `scheduleTokenRefresh()`: Schedules automatic token refresh
|
||||
- `checkAndScheduleRefresh()`: Checks current session and schedules refresh
|
||||
- Event listeners for route changes and tab visibility
|
||||
|
||||
### 3. Enhanced Error Handling
|
||||
|
||||
**Files Modified:**
|
||||
- `composables/useCustomAuth.ts`
|
||||
|
||||
**Improvements:**
|
||||
- Added retry logic for network errors (502, 503, etc.)
|
||||
- Better distinction between auth failures (401) and network issues (5xx)
|
||||
- Exponential backoff for retries
|
||||
- Maintains auth state during network issues vs clearing on auth failures
|
||||
|
||||
### 4. Environment Configuration
|
||||
|
||||
**Files Modified:**
|
||||
- `.env.example`
|
||||
|
||||
**New Variables:**
|
||||
```env
|
||||
# Cookie Configuration
|
||||
COOKIE_DOMAIN=.portnimara.dev
|
||||
|
||||
# Keycloak Configuration (used by custom auth)
|
||||
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||
```
|
||||
|
||||
## Keycloak Admin Console Settings
|
||||
|
||||
These settings were configured in Keycloak to support longer sessions:
|
||||
|
||||
### Realm Settings → Tokens
|
||||
- **SSO Session Idle**: 8 hours
|
||||
- **SSO Session Max**: 12 hours
|
||||
- **Access Token Lifespan**: 5 minutes (kept short for security)
|
||||
- **Client Session Idle**: 8 hours
|
||||
- **Client Session Max**: 12 hours
|
||||
|
||||
### Client Settings
|
||||
- **Use Refresh Tokens**: ON
|
||||
- **Refresh Token Max Reuse**: 0 (unlimited)
|
||||
|
||||
## How It Works Now
|
||||
|
||||
1. **Initial Login**: User authenticates via Keycloak, gets 8-hour session cookie
|
||||
2. **Token Refresh**: Access tokens refreshed automatically every ~3 minutes
|
||||
3. **Session Management**: Session lasts 8 hours or until user explicitly logs out
|
||||
4. **Error Recovery**: Network errors trigger retries; auth errors trigger re-login
|
||||
5. **Tab Management**: Returning to tab triggers session check and refresh if needed
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
- **Users stay logged in**: For up to 8 hours of activity
|
||||
- **No manual re-login**: Unless session truly expires or refresh tokens become invalid
|
||||
- **Graceful error handling**: 502 errors are retried; true auth failures redirect to login
|
||||
- **Background refresh**: Tokens refresh automatically without user interaction
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
1. Login and monitor console logs for refresh scheduling
|
||||
2. Leave tab open for >5 minutes, verify no logout
|
||||
3. Close/reopen tab, verify automatic session restoration
|
||||
4. Test with network disconnection/reconnection
|
||||
|
||||
### Console Logs to Monitor
|
||||
```
|
||||
[AUTH_REFRESH] Scheduling token refresh in: X ms
|
||||
[AUTH_REFRESH] Attempting automatic token refresh...
|
||||
[AUTH_REFRESH] Token refresh successful, scheduling next refresh
|
||||
[CUSTOM_AUTH] Session check result: { authenticated: true, userId: 'xxx' }
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Users Still Getting Logged Out
|
||||
1. Check Keycloak realm token settings
|
||||
2. Verify `COOKIE_DOMAIN` environment variable
|
||||
3. Check browser console for refresh errors
|
||||
4. Verify Keycloak client refresh token settings
|
||||
|
||||
### 502 Errors Persist
|
||||
1. Check nginx configuration and upstream health
|
||||
2. Verify network connectivity between services
|
||||
3. Monitor nginx error logs for backend issues
|
||||
4. Check if retry logic is working in browser console
|
||||
|
||||
### Refresh Not Working
|
||||
1. Verify refresh tokens are being issued by Keycloak
|
||||
2. Check client secret configuration
|
||||
3. Monitor network tab for refresh API calls
|
||||
4. Ensure auto-refresh plugin is loading (check console)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Access tokens remain short-lived (5 minutes) for security
|
||||
- Refresh tokens enable long sessions without storing access tokens long-term
|
||||
- Session cookies are httpOnly, secure, and sameSite protected
|
||||
- Domain restrictions prevent cross-site cookie access
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add user notification before session expiry
|
||||
- [ ] Implement sliding session extension on user activity
|
||||
- [ ] Add session management UI for users
|
||||
- [ ] Monitor and alert on high refresh failure rates
|
||||
- [ ] Add metrics for session duration and refresh success rates
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Date Formatting Consistency Fix
|
||||
|
||||
## Problem Identified
|
||||
|
||||
Two different creation dates were being displayed for the same interest record:
|
||||
|
||||
1. **Card description (mobile view)**: Showed "Created: 15/05/2025 02:06" ✅ (correct)
|
||||
2. **Interests table summary**: Showed "Created: Dec 15, 1920 (104 years ago)" ❌ (incorrect)
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The issue was caused by **inconsistent date formatting functions** across different components:
|
||||
|
||||
- **InterestDetailsModal.vue**: Had a robust `formatDate` function that correctly handled multiple date formats (ISO, DD-MM-YYYY, YYYY-MM-DD)
|
||||
- **interest-list.vue**: Had a simpler `formatDate` function that incorrectly parsed dates, causing the 1920 vs 2025 issue
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. Created Unified Date Utilities (`utils/dateUtils.ts`)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Unified date formatting utilities for consistent date display across the application
|
||||
*/
|
||||
|
||||
export const formatDate = (dateString: string | null | undefined): string => {
|
||||
// Handles multiple input formats: ISO, DD-MM-YYYY, YYYY-MM-DD, DD/MM/YYYY
|
||||
// Returns format: DD/MM/YYYY HH:mm or DD/MM/YYYY
|
||||
}
|
||||
|
||||
export const getRelativeTime = (dateString: string | null | undefined): string => {
|
||||
// Returns: "Today", "Yesterday", "2 days ago", "3 weeks ago", etc.
|
||||
}
|
||||
|
||||
export const formatDateUS = (dateString: string | null | undefined): string => {
|
||||
// Returns US format: "Month DD, YYYY" (e.g., "Dec 15, 2025")
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Updated Components to Use Unified Utilities
|
||||
|
||||
**interest-list.vue changes:**
|
||||
- ✅ Added import: `import { formatDate, formatDateUS, getRelativeTime } from "@/utils/dateUtils"`
|
||||
- ✅ Removed duplicate `formatDate` function (39 lines removed)
|
||||
- ✅ Removed duplicate `getRelativeTime` function (32 lines removed)
|
||||
- ✅ Now uses unified utilities for consistent date formatting
|
||||
|
||||
**InterestDetailsModal.vue changes:**
|
||||
- ✅ Added import: `import { formatDate } from "@/utils/dateUtils"`
|
||||
- ✅ Removed duplicate `formatDate` function (45 lines removed)
|
||||
- ✅ Now uses unified utility for consistent date formatting
|
||||
|
||||
### 3. Robust Date Parsing Logic
|
||||
|
||||
The unified `formatDate` function handles multiple input formats:
|
||||
- ✅ ISO dates: `"2025-06-09T22:58:47.731Z"`
|
||||
- ✅ DD-MM-YYYY: `"15-05-2025"`
|
||||
- ✅ DD/MM/YYYY: `"15/05/2025"`
|
||||
- ✅ YYYY-MM-DD: `"2025-05-15"`
|
||||
- ✅ Includes time when not midnight: `"15/05/2025 02:06"`
|
||||
- ✅ Graceful error handling and fallbacks
|
||||
|
||||
## Result
|
||||
|
||||
✅ **Both views now show consistent, correctly formatted dates**
|
||||
✅ **No more 1920 date errors**
|
||||
✅ **Unified date formatting across the entire application**
|
||||
✅ **Reduced code duplication (116 lines of duplicate code removed)**
|
||||
✅ **More maintainable and reliable date handling**
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `utils/dateUtils.ts` - **NEW** unified date utilities
|
||||
2. `pages/dashboard/interest-list.vue` - Updated to use unified utilities
|
||||
3. `components/InterestDetailsModal.vue` - Updated to use unified utilities
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Development server runs without compilation errors
|
||||
- ✅ Components load successfully with unified date formatting
|
||||
- ✅ Ready for user testing to verify date consistency
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Consistency**: All date displays use the same formatting logic
|
||||
2. **Maintainability**: Single source of truth for date formatting
|
||||
3. **Reliability**: Robust parsing handles multiple date formats
|
||||
4. **Extensibility**: Easy to add new date formatting functions as needed
|
||||
5. **Performance**: Removed duplicate code and processing
|
||||
|
|
@ -340,6 +340,7 @@ import ContractStatusBadge from "~/components/ContractStatusBadge.vue";
|
|||
import { useFetch } from "#app";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Interest } from "@/utils/types";
|
||||
import { formatDate, formatDateUS, getRelativeTime } from "@/utils/dateUtils";
|
||||
|
||||
useHead({
|
||||
title: "Interest List",
|
||||
|
|
@ -428,50 +429,6 @@ const getSalesStatusColor = (status: string) => {
|
|||
return 'grey';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return "-";
|
||||
|
||||
try {
|
||||
// Handle DD-MM-YYYY format
|
||||
const parts = dateString.split("-");
|
||||
if (parts.length === 3) {
|
||||
const day = parseInt(parts[0], 10);
|
||||
const month = parseInt(parts[1], 10) - 1; // Month is 0-indexed in JavaScript
|
||||
const year = parseInt(parts[2], 10);
|
||||
|
||||
const date = new Date(year, month, day);
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn("Invalid date format:", dateString);
|
||||
return "-";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard Date parsing
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn("Invalid date format:", dateString);
|
||||
return "-";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error formatting date:", dateString, error);
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInterests = computed(() => {
|
||||
if (!interests.value?.list) return [];
|
||||
|
|
@ -526,42 +483,6 @@ const getInitials = (name: string) => {
|
|||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Helper function to get relative time
|
||||
const getRelativeTime = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
let date: Date;
|
||||
|
||||
// Handle DD-MM-YYYY format
|
||||
if (dateString.includes('-')) {
|
||||
const parts = dateString.split('-');
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
} else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
} else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
export default defineNuxtPlugin(() => {
|
||||
// Only run on client side
|
||||
if (import.meta.server) return
|
||||
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
let isRefreshing = false
|
||||
|
||||
const scheduleTokenRefresh = (expiresAt: number) => {
|
||||
// Clear existing timer
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
|
||||
// Calculate time until refresh (refresh 2 minutes before expiry)
|
||||
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
|
||||
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
|
||||
|
||||
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
|
||||
|
||||
// Only schedule if we have time left
|
||||
if (timeUntilRefresh > 0) {
|
||||
refreshTimer = setTimeout(async () => {
|
||||
if (isRefreshing) return
|
||||
|
||||
try {
|
||||
isRefreshing = true
|
||||
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
||||
|
||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.success && response.expiresAt) {
|
||||
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
||||
scheduleTokenRefresh(response.expiresAt)
|
||||
} else {
|
||||
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
||||
await navigateTo('/login')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
||||
// If refresh fails, redirect to login
|
||||
await navigateTo('/login')
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}, timeUntilRefresh)
|
||||
} else {
|
||||
// Token already expired or very close to expiry, try immediate refresh
|
||||
setTimeout(async () => {
|
||||
if (isRefreshing) return
|
||||
|
||||
try {
|
||||
isRefreshing = true
|
||||
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
||||
|
||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.success && response.expiresAt) {
|
||||
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
||||
scheduleTokenRefresh(response.expiresAt)
|
||||
} else {
|
||||
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
||||
await navigateTo('/login')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
|
||||
await navigateTo('/login')
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}, 100) // Small delay to avoid immediate execution
|
||||
}
|
||||
}
|
||||
|
||||
const checkAndScheduleRefresh = async () => {
|
||||
try {
|
||||
const sessionData = await $fetch<{ user: any; authenticated: boolean }>('/api/auth/session')
|
||||
|
||||
if (sessionData.authenticated) {
|
||||
// Get the session cookie to extract expiry time
|
||||
const sessionCookie = useCookie('nuxt-oidc-auth')
|
||||
|
||||
if (sessionCookie.value) {
|
||||
try {
|
||||
const parsedSession = typeof sessionCookie.value === 'string'
|
||||
? JSON.parse(sessionCookie.value)
|
||||
: sessionCookie.value
|
||||
|
||||
if (parsedSession.expiresAt) {
|
||||
console.log('[AUTH_REFRESH] Found session with expiry:', new Date(parsedSession.expiresAt))
|
||||
scheduleTokenRefresh(parsedSession.expiresAt)
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('[AUTH_REFRESH] Failed to parse session cookie:', parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Failed to check session:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication status and schedule refresh on plugin initialization
|
||||
onMounted(() => {
|
||||
checkAndScheduleRefresh()
|
||||
})
|
||||
|
||||
// Listen for route changes to re-check auth status
|
||||
const router = useRouter()
|
||||
router.afterEach((to) => {
|
||||
// Only check on protected routes
|
||||
if (to.meta.auth !== false) {
|
||||
checkAndScheduleRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for visibility changes to refresh when tab becomes active
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
// Tab became visible, check if we need to refresh
|
||||
checkAndScheduleRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up timer on plugin destruction
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -81,13 +81,17 @@ export default defineEventHandler(async (event) => {
|
|||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
// Create session cookie with better security settings
|
||||
// Create session cookie with proper session duration (8 hours = 28800 seconds)
|
||||
// Not tied to access token lifetime since we'll refresh tokens automatically
|
||||
const sessionDuration = 8 * 60 * 60; // 8 hours in seconds
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
|
||||
setCookie(event, 'nuxt-oidc-auth', JSON.stringify(sessionData), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: tokenResponse.expires_in,
|
||||
domain: '.portnimara.dev',
|
||||
maxAge: sessionDuration,
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -72,13 +72,16 @@ export default defineEventHandler(async (event) => {
|
|||
refreshedAt: Date.now()
|
||||
}
|
||||
|
||||
// Set updated session cookie
|
||||
// Set updated session cookie with proper session duration
|
||||
const sessionDuration = 8 * 60 * 60; // 8 hours in seconds
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
|
||||
setCookie(event, 'nuxt-oidc-auth', JSON.stringify(updatedSessionData), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: tokenResponse.expires_in,
|
||||
domain: '.portnimara.dev',
|
||||
maxAge: sessionDuration,
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
|
|
@ -93,8 +96,9 @@ export default defineEventHandler(async (event) => {
|
|||
console.error('[REFRESH] Token refresh failed:', error)
|
||||
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: '.portnimara.dev',
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ export default defineEventHandler(async (event) => {
|
|||
} catch (parseError) {
|
||||
console.error('[SESSION] Failed to parse session cookie:', parseError)
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: '.portnimara.dev',
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
|
|
@ -39,8 +40,9 @@ export default defineEventHandler(async (event) => {
|
|||
hasUser: !!sessionData.user,
|
||||
hasAccessToken: !!sessionData.accessToken
|
||||
})
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: '.portnimara.dev',
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
|
|
@ -54,8 +56,9 @@ export default defineEventHandler(async (event) => {
|
|||
expiredSince: Date.now() - sessionData.expiresAt
|
||||
})
|
||||
// Session expired, clear cookie
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: '.portnimara.dev',
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
|
|
@ -80,8 +83,9 @@ export default defineEventHandler(async (event) => {
|
|||
} catch (error) {
|
||||
console.error('[SESSION] OIDC session check error:', error)
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: '.portnimara.dev',
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Unified date formatting utilities for consistent date display across the application
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format date string to display format (DD/MM/YYYY HH:mm or DD/MM/YYYY)
|
||||
* Handles multiple input formats: ISO, DD-MM-YYYY, YYYY-MM-DD, DD/MM/YYYY
|
||||
*/
|
||||
export const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return "-";
|
||||
|
||||
try {
|
||||
let date: Date;
|
||||
|
||||
// Check if it's an ISO date string (e.g., "2025-06-09T22:58:47.731Z")
|
||||
if (dateString.includes('T') || dateString.includes('Z')) {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
// Handle DD-MM-YYYY format
|
||||
else if (dateString.match(/^\d{1,2}-\d{1,2}-\d{4}$/)) {
|
||||
const [day, month, year] = dateString.split("-");
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
// Handle DD/MM/YYYY format
|
||||
else if (dateString.match(/^\d{1,2}\/\d{1,2}\/\d{4}$/)) {
|
||||
const [day, month, year] = dateString.split("/");
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
// Handle YYYY-MM-DD format
|
||||
else if (dateString.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
// Fallback to direct parsing
|
||||
else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn("Invalid date format:", dateString);
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// Format date in DD/MM/YYYY HH:mm format
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Include time if it's not midnight
|
||||
if (hours !== '00' || minutes !== '00') {
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
return `${day}/${month}/${year}`;
|
||||
} catch (error) {
|
||||
console.error('Date formatting error:', error, dateString);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get relative time description (e.g., "2 days ago", "Yesterday", "Today")
|
||||
*/
|
||||
export const getRelativeTime = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
let date: Date;
|
||||
|
||||
// Handle DD-MM-YYYY format
|
||||
if (dateString.includes('-') && dateString.match(/^\d{1,2}-\d{1,2}-\d{4}$/)) {
|
||||
const parts = dateString.split('-');
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
} else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
} else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
} catch (error) {
|
||||
console.error('Relative time calculation error:', error, dateString);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date for display in US locale (Month DD, YYYY)
|
||||
*/
|
||||
export const formatDateUS = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return "-";
|
||||
|
||||
try {
|
||||
let date: Date;
|
||||
|
||||
// Handle DD-MM-YYYY format
|
||||
if (dateString.includes('-') && dateString.match(/^\d{1,2}-\d{1,2}-\d{4}$/)) {
|
||||
const parts = dateString.split('-');
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
} else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
} else {
|
||||
date = new Date(dateString);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn("Invalid date format:", dateString);
|
||||
return "-";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error formatting date:", dateString, error);
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue