feat(pwa): implement service worker and PWA enhancements
- Added service worker (sw.js) for offline functionality, caching strategies, and performance improvements. - Registered service worker in App component to manage updates and offline readiness. - Introduced ServiceWorkerStatus component to display connection status and provide cache management controls. - Created manifest.json for PWA configuration, including app name, icons, and display settings. - Updated index.html with PWA meta tags and links to support mobile web app capabilities. - Refactored authentication guards to utilize useAuthStatus hook for improved user state management. - Removed deprecated unregister-sw.js file to streamline service worker management.
This commit is contained in:
@@ -6,6 +6,27 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#2b2b2b" />
|
<meta name="theme-color" content="#2b2b2b" />
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="application-name" content="Worklenz" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Worklenz" />
|
||||||
|
<meta name="description" content="A comprehensive project management application for teams" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||||
|
<meta name="msapplication-TileColor" content="#2b2b2b" />
|
||||||
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
|
|
||||||
|
<!-- Apple Touch Icons -->
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="/favicon.ico" />
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
<!-- Resource hints for better loading performance -->
|
<!-- Resource hints for better loading performance -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
78
worklenz-frontend/public/manifest.json
Normal file
78
worklenz-frontend/public/manifest.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"name": "Worklenz - Project Management",
|
||||||
|
"short_name": "Worklenz",
|
||||||
|
"description": "A comprehensive project management application for teams",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#2b2b2b",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
|
"lang": "en",
|
||||||
|
"scope": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.ico",
|
||||||
|
"sizes": "16x16 32x32 48x48",
|
||||||
|
"type": "image/x-icon",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"short_name": "Dashboard",
|
||||||
|
"description": "View your project dashboard",
|
||||||
|
"url": "/worklenz",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.ico",
|
||||||
|
"sizes": "16x16 32x32 48x48"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tasks",
|
||||||
|
"short_name": "Tasks",
|
||||||
|
"description": "Manage your tasks",
|
||||||
|
"url": "/worklenz/tasks",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.ico",
|
||||||
|
"sizes": "16x16 32x32 48x48"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/assets/images/screenshot-desktop.png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/images/screenshot-mobile.png",
|
||||||
|
"sizes": "480x854",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"related_applications": [],
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "focus-existing"
|
||||||
|
}
|
||||||
|
}
|
||||||
345
worklenz-frontend/public/sw.js
Normal file
345
worklenz-frontend/public/sw.js
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
// Worklenz Service Worker
|
||||||
|
// Provides offline functionality, caching, and performance improvements
|
||||||
|
|
||||||
|
const CACHE_VERSION = 'v1.0.0';
|
||||||
|
const CACHE_NAMES = {
|
||||||
|
STATIC: `worklenz-static-${CACHE_VERSION}`,
|
||||||
|
DYNAMIC: `worklenz-dynamic-${CACHE_VERSION}`,
|
||||||
|
API: `worklenz-api-${CACHE_VERSION}`,
|
||||||
|
IMAGES: `worklenz-images-${CACHE_VERSION}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resources to cache immediately on install
|
||||||
|
const STATIC_CACHE_URLS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/env-config.js',
|
||||||
|
'/manifest.json',
|
||||||
|
// Ant Design and other critical CSS/JS will be cached as they're requested
|
||||||
|
];
|
||||||
|
|
||||||
|
// API endpoints that can be cached
|
||||||
|
const CACHEABLE_API_PATTERNS = [
|
||||||
|
/\/api\/project-categories/,
|
||||||
|
/\/api\/project-statuses/,
|
||||||
|
/\/api\/task-priorities/,
|
||||||
|
/\/api\/task-statuses/,
|
||||||
|
/\/api\/job-titles/,
|
||||||
|
/\/api\/teams\/\d+\/members/,
|
||||||
|
/\/api\/auth\/user/, // Cache user info for offline access
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resources that should never be cached
|
||||||
|
const NEVER_CACHE_PATTERNS = [
|
||||||
|
/\/api\/auth\/login/,
|
||||||
|
/\/api\/auth\/logout/,
|
||||||
|
/\/api\/notifications/,
|
||||||
|
/\/socket\.io/,
|
||||||
|
/\.hot-update\./,
|
||||||
|
/sw\.js$/,
|
||||||
|
/chrome-extension/,
|
||||||
|
/moz-extension/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - Cache static resources
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
console.log('Service Worker: Installing...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(CACHE_NAMES.STATIC);
|
||||||
|
await cache.addAll(STATIC_CACHE_URLS);
|
||||||
|
console.log('Service Worker: Static resources cached');
|
||||||
|
|
||||||
|
// Skip waiting to activate immediately
|
||||||
|
await self.skipWaiting();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker: Installation failed', error);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - Clean up old caches
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
console.log('Service Worker: Activating...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Clean up old caches
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
const oldCaches = cacheNames.filter(name =>
|
||||||
|
Object.values(CACHE_NAMES).every(currentCache => currentCache !== name)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
oldCaches.map(cacheName => caches.delete(cacheName))
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Service Worker: Old caches cleaned up');
|
||||||
|
|
||||||
|
// Take control of all pages
|
||||||
|
await self.clients.claim();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker: Activation failed', error);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - Handle all network requests
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests and browser extensions
|
||||||
|
if (request.method !== 'GET' || NEVER_CACHE_PATTERNS.some(pattern => pattern.test(url.href))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(handleFetchRequest(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main fetch handler with different strategies based on resource type
|
||||||
|
async function handleFetchRequest(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Static assets - Cache First strategy
|
||||||
|
if (isStaticAsset(url)) {
|
||||||
|
return await cacheFirstStrategy(request, CACHE_NAMES.STATIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images - Cache First with long-term storage
|
||||||
|
if (isImageRequest(url)) {
|
||||||
|
return await cacheFirstStrategy(request, CACHE_NAMES.IMAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests - Network First with fallback
|
||||||
|
if (isAPIRequest(url)) {
|
||||||
|
return await networkFirstStrategy(request, CACHE_NAMES.API);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages - Stale While Revalidate
|
||||||
|
if (isHTMLRequest(request)) {
|
||||||
|
return await staleWhileRevalidateStrategy(request, CACHE_NAMES.DYNAMIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else - Network First
|
||||||
|
return await networkFirstStrategy(request, CACHE_NAMES.DYNAMIC);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker: Fetch failed', error);
|
||||||
|
return createOfflineResponse(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache First Strategy - Try cache first, fallback to network
|
||||||
|
async function cacheFirstStrategy(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse.status === 200) {
|
||||||
|
// Clone before caching as response can only be used once
|
||||||
|
const responseClone = networkResponse.clone();
|
||||||
|
await cache.put(request, responseClone);
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache First: Network failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network First Strategy - Try network first, fallback to cache
|
||||||
|
async function networkFirstStrategy(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
if (networkResponse.status === 200) {
|
||||||
|
// Cache successful responses
|
||||||
|
const responseClone = networkResponse.clone();
|
||||||
|
await cache.put(request, responseClone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Network First: Network failed, trying cache', error);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale While Revalidate Strategy - Return cached version while updating in background
|
||||||
|
async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
// Fetch from network in background
|
||||||
|
const networkResponsePromise = fetch(request).then(async networkResponse => {
|
||||||
|
if (networkResponse.status === 200) {
|
||||||
|
const responseClone = networkResponse.clone();
|
||||||
|
await cache.put(request, responseClone);
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
}).catch(error => {
|
||||||
|
console.warn('Stale While Revalidate: Background update failed', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return cached version immediately if available
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cached version, wait for network
|
||||||
|
return await networkResponsePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to identify resource types
|
||||||
|
function isStaticAsset(url) {
|
||||||
|
return /\.(js|css|woff2?|ttf|eot)$/.test(url.pathname) ||
|
||||||
|
url.pathname.includes('/assets/') ||
|
||||||
|
url.pathname === '/' ||
|
||||||
|
url.pathname === '/index.html' ||
|
||||||
|
url.pathname === '/favicon.ico' ||
|
||||||
|
url.pathname === '/env-config.js';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageRequest(url) {
|
||||||
|
return /\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(url.pathname) ||
|
||||||
|
url.pathname.includes('/file-types/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAPIRequest(url) {
|
||||||
|
return url.pathname.startsWith('/api/') ||
|
||||||
|
CACHEABLE_API_PATTERNS.some(pattern => pattern.test(url.pathname));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHTMLRequest(request) {
|
||||||
|
return request.headers.get('accept')?.includes('text/html');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create offline fallback response
|
||||||
|
function createOfflineResponse(request) {
|
||||||
|
if (isImageRequest(new URL(request.url))) {
|
||||||
|
// Return a simple SVG placeholder for images
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
|
||||||
|
<rect width="200" height="150" fill="#f0f0f0"/>
|
||||||
|
<text x="100" y="75" text-anchor="middle" fill="#999" font-family="Arial, sans-serif" font-size="14">
|
||||||
|
Offline
|
||||||
|
</text>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return new Response(svg, {
|
||||||
|
headers: { 'Content-Type': 'image/svg+xml' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAPIRequest(new URL(request.url))) {
|
||||||
|
// Return empty array or error for API requests
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Offline',
|
||||||
|
message: 'This feature requires an internet connection'
|
||||||
|
}), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTML requests, try to return cached index.html
|
||||||
|
return caches.match('/') || new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle background sync events (for future implementation)
|
||||||
|
self.addEventListener('sync', event => {
|
||||||
|
console.log('Service Worker: Background sync', event.tag);
|
||||||
|
|
||||||
|
if (event.tag === 'background-sync') {
|
||||||
|
event.waitUntil(handleBackgroundSync());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleBackgroundSync() {
|
||||||
|
// This is where you would handle queued actions when coming back online
|
||||||
|
console.log('Service Worker: Handling background sync');
|
||||||
|
|
||||||
|
// Example: Send queued task updates, sync offline changes, etc.
|
||||||
|
// Implementation would depend on your app's specific needs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle push notification events (for future implementation)
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: event.data.text(),
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
badge: '/favicon.ico',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
data: {
|
||||||
|
dateOfArrival: Date.now(),
|
||||||
|
primaryKey: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification('Worklenz', options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification click events
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.openWindow('/')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message handling for communication with main thread
|
||||||
|
self.addEventListener('message', event => {
|
||||||
|
const { type, payload } = event.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'SKIP_WAITING':
|
||||||
|
self.skipWaiting();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'GET_VERSION':
|
||||||
|
event.ports[0].postMessage({ version: CACHE_VERSION });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'CLEAR_CACHE':
|
||||||
|
clearAllCaches().then(() => {
|
||||||
|
event.ports[0].postMessage({ success: true });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Service Worker: Unknown message type', type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function clearAllCaches() {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||||
|
console.log('Service Worker: All caches cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Service Worker: Loaded successfully');
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
if ('serviceWorker' in navigator) {
|
|
||||||
// Check if we've already attempted to unregister in this session
|
|
||||||
if (!sessionStorage.getItem('swUnregisterAttempted')) {
|
|
||||||
navigator.serviceWorker.getRegistrations().then(function (registrations) {
|
|
||||||
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
|
||||||
|
|
||||||
if (ngswWorker) {
|
|
||||||
// Mark that we've attempted to unregister
|
|
||||||
sessionStorage.setItem('swUnregisterAttempted', 'true');
|
|
||||||
// Unregister the ngsw-worker
|
|
||||||
ngswWorker.unregister().then(() => {
|
|
||||||
// Reload the page after unregistering
|
|
||||||
window.location.reload(true);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// If no ngsw-worker is found, unregister any other service workers
|
|
||||||
for (let registration of registrations) {
|
|
||||||
registration.unregister();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,9 @@ import { Language } from './features/i18n/localesSlice';
|
|||||||
import logger from './utils/errorLogger';
|
import logger from './utils/errorLogger';
|
||||||
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
|
import { SuspenseFallback } from './components/suspense-fallback/suspense-fallback';
|
||||||
|
|
||||||
|
// Service Worker
|
||||||
|
import { registerSW } from './utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main App Component - Performance Optimized
|
* Main App Component - Performance Optimized
|
||||||
*
|
*
|
||||||
@@ -96,6 +99,25 @@ const App: React.FC = memo(() => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Register service worker
|
||||||
|
useEffect(() => {
|
||||||
|
registerSW({
|
||||||
|
onSuccess: (registration) => {
|
||||||
|
console.log('Service Worker registered successfully', registration);
|
||||||
|
},
|
||||||
|
onUpdate: (registration) => {
|
||||||
|
console.log('New content is available and will be used when all tabs for this page are closed.');
|
||||||
|
// You could show a toast notification here for user to refresh
|
||||||
|
},
|
||||||
|
onOfflineReady: () => {
|
||||||
|
console.log('This web app has been cached for offline use.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
logger.error('Service Worker registration failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Defer non-critical initialization
|
// Defer non-critical initialization
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeNonCriticalApp = () => {
|
const initializeNonCriticalApp = () => {
|
||||||
|
|||||||
@@ -29,24 +29,12 @@ const withCodeSplitting = (Component: React.LazyExoticComponent<React.ComponentT
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Memoized guard components with defensive programming
|
// Memoized guard components with defensive programming
|
||||||
|
import { useAuthStatus } from '@/hooks/useAuthStatus';
|
||||||
|
|
||||||
export const AuthGuard = memo(({ children }: GuardProps) => {
|
export const AuthGuard = memo(({ children }: GuardProps) => {
|
||||||
const authService = useAuthService();
|
const { isAuthenticated, location } = useAuthStatus();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const shouldRedirect = useMemo(() => {
|
if (!isAuthenticated) {
|
||||||
try {
|
|
||||||
// Defensive check to ensure authService and its methods exist
|
|
||||||
if (!authService || typeof authService.isAuthenticated !== 'function') {
|
|
||||||
return false; // Don't redirect if auth service is not ready
|
|
||||||
}
|
|
||||||
return !authService.isAuthenticated();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in AuthGuard:', error);
|
|
||||||
return false; // Don't redirect on error, let the app handle it
|
|
||||||
}
|
|
||||||
}, [authService]);
|
|
||||||
|
|
||||||
if (shouldRedirect) {
|
|
||||||
return <Navigate to="/auth" state={{ from: location }} replace />;
|
return <Navigate to="/auth" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,41 +44,14 @@ export const AuthGuard = memo(({ children }: GuardProps) => {
|
|||||||
AuthGuard.displayName = 'AuthGuard';
|
AuthGuard.displayName = 'AuthGuard';
|
||||||
|
|
||||||
export const AdminGuard = memo(({ children }: GuardProps) => {
|
export const AdminGuard = memo(({ children }: GuardProps) => {
|
||||||
const authService = useAuthService();
|
const { isAuthenticated, isAdmin, location } = useAuthStatus();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const guardResult = useMemo(() => {
|
if (!isAuthenticated) {
|
||||||
try {
|
return <Navigate to="/auth" state={{ from: location }} replace />;
|
||||||
// Defensive checks to ensure authService and its methods exist
|
}
|
||||||
if (
|
|
||||||
!authService ||
|
|
||||||
typeof authService.isAuthenticated !== 'function' ||
|
|
||||||
typeof authService.isOwnerOrAdmin !== 'function' ||
|
|
||||||
typeof authService.getCurrentSession !== 'function'
|
|
||||||
) {
|
|
||||||
return null; // Don't redirect if auth service is not ready
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authService.isAuthenticated()) {
|
if (!isAdmin) {
|
||||||
return { redirect: '/auth', state: { from: location } };
|
return <Navigate to="/worklenz/unauthorized" />;
|
||||||
}
|
|
||||||
|
|
||||||
const currentSession = authService.getCurrentSession();
|
|
||||||
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
|
|
||||||
|
|
||||||
if (!authService.isOwnerOrAdmin() || isFreePlan) {
|
|
||||||
return { redirect: '/worklenz/unauthorized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in AdminGuard:', error);
|
|
||||||
return null; // Don't redirect on error
|
|
||||||
}
|
|
||||||
}, [authService, location]);
|
|
||||||
|
|
||||||
if (guardResult) {
|
|
||||||
return <Navigate to={guardResult.redirect} state={guardResult.state} replace />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@@ -99,77 +60,12 @@ export const AdminGuard = memo(({ children }: GuardProps) => {
|
|||||||
AdminGuard.displayName = 'AdminGuard';
|
AdminGuard.displayName = 'AdminGuard';
|
||||||
|
|
||||||
export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
|
export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
|
||||||
const authService = useAuthService();
|
const { isLicenseExpired, location } = useAuthStatus();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const shouldRedirect = useMemo(() => {
|
const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center');
|
||||||
try {
|
const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired';
|
||||||
// Defensive checks to ensure authService and its methods exist
|
|
||||||
if (
|
|
||||||
!authService ||
|
|
||||||
typeof authService.isAuthenticated !== 'function' ||
|
|
||||||
typeof authService.getCurrentSession !== 'function'
|
|
||||||
) {
|
|
||||||
return false; // Don't redirect if auth service is not ready
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authService.isAuthenticated()) return false;
|
if (isLicenseExpired && !isAdminCenterRoute && !isLicenseExpiredRoute) {
|
||||||
|
|
||||||
const isAdminCenterRoute = location.pathname.includes('/worklenz/admin-center');
|
|
||||||
const isLicenseExpiredRoute = location.pathname === '/worklenz/license-expired';
|
|
||||||
|
|
||||||
// Don't check or redirect if we're already on the license-expired page
|
|
||||||
if (isLicenseExpiredRoute) return false;
|
|
||||||
|
|
||||||
const currentSession = authService.getCurrentSession();
|
|
||||||
|
|
||||||
// Check if trial is expired more than 7 days or if is_expired flag is set
|
|
||||||
const isLicenseExpiredMoreThan7Days = () => {
|
|
||||||
// Quick bail if no session data is available
|
|
||||||
if (!currentSession) return false;
|
|
||||||
|
|
||||||
// Check is_expired flag first
|
|
||||||
if (currentSession.is_expired) {
|
|
||||||
// If no trial_expire_date exists but is_expired is true, defer to backend check
|
|
||||||
if (!currentSession.trial_expire_date) return true;
|
|
||||||
|
|
||||||
// If there is a trial_expire_date, check if it's more than 7 days past
|
|
||||||
const today = new Date();
|
|
||||||
const expiryDate = new Date(currentSession.trial_expire_date);
|
|
||||||
const diffTime = today.getTime() - expiryDate.getTime();
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
// Redirect if more than 7 days past expiration
|
|
||||||
return diffDays > 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not marked as expired but has trial_expire_date, do a date check
|
|
||||||
if (
|
|
||||||
currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL &&
|
|
||||||
currentSession.trial_expire_date
|
|
||||||
) {
|
|
||||||
const today = new Date();
|
|
||||||
const expiryDate = new Date(currentSession.trial_expire_date);
|
|
||||||
|
|
||||||
const diffTime = today.getTime() - expiryDate.getTime();
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
// If expired more than 7 days, redirect
|
|
||||||
return diffDays > 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No expiration data found
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return isLicenseExpiredMoreThan7Days() && !isAdminCenterRoute;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in LicenseExpiryGuard:', error);
|
|
||||||
return false; // Don't redirect on error
|
|
||||||
}
|
|
||||||
}, [authService, location.pathname]);
|
|
||||||
|
|
||||||
if (shouldRedirect) {
|
|
||||||
return <Navigate to="/worklenz/license-expired" replace />;
|
return <Navigate to="/worklenz/license-expired" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,26 +75,16 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
|
|||||||
LicenseExpiryGuard.displayName = 'LicenseExpiryGuard';
|
LicenseExpiryGuard.displayName = 'LicenseExpiryGuard';
|
||||||
|
|
||||||
export const SetupGuard = memo(({ children }: GuardProps) => {
|
export const SetupGuard = memo(({ children }: GuardProps) => {
|
||||||
const authService = useAuthService();
|
const { isAuthenticated, isSetupComplete, location } = useAuthStatus();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const shouldRedirect = useMemo(() => {
|
if (!isAuthenticated) {
|
||||||
try {
|
|
||||||
// Defensive check to ensure authService and its methods exist
|
|
||||||
if (!authService || typeof authService.isAuthenticated !== 'function') {
|
|
||||||
return false; // Don't redirect if auth service is not ready
|
|
||||||
}
|
|
||||||
return !authService.isAuthenticated();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in SetupGuard:', error);
|
|
||||||
return false; // Don't redirect on error
|
|
||||||
}
|
|
||||||
}, [authService]);
|
|
||||||
|
|
||||||
if (shouldRedirect) {
|
|
||||||
return <Navigate to="/auth" state={{ from: location }} replace />;
|
return <Navigate to="/auth" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSetupComplete) {
|
||||||
|
return <Navigate to="/worklenz/setup" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
17
worklenz-frontend/src/app/routes/utils.ts
Normal file
17
worklenz-frontend/src/app/routes/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from 'react-router-dom';
|
||||||
|
import { store } from '../store';
|
||||||
|
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||||
|
|
||||||
|
export const authLoader = async () => {
|
||||||
|
const session = await store.dispatch(verifyAuthentication()).unwrap();
|
||||||
|
|
||||||
|
if (!session.user) {
|
||||||
|
return redirect('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.is_expired) {
|
||||||
|
return redirect('/worklenz/license-expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Service Worker Status Component
|
||||||
|
// Shows offline status and provides cache management controls
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Badge, Button, Space, Tooltip, message } from 'antd';
|
||||||
|
import { WifiOutlined, DisconnectOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
interface ServiceWorkerStatusProps {
|
||||||
|
minimal?: boolean; // Show only basic offline indicator
|
||||||
|
showControls?: boolean; // Show cache management controls
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||||
|
minimal = false,
|
||||||
|
showControls = false
|
||||||
|
}) => {
|
||||||
|
const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker();
|
||||||
|
const [swVersion, setSwVersion] = React.useState<string>('');
|
||||||
|
const [isClearing, setIsClearing] = React.useState(false);
|
||||||
|
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||||
|
|
||||||
|
// Get service worker version on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (getVersion) {
|
||||||
|
const versionPromise = getVersion();
|
||||||
|
if (versionPromise) {
|
||||||
|
versionPromise.then(version => {
|
||||||
|
setSwVersion(version);
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore errors when getting version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [getVersion]);
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
if (!clearCache) return;
|
||||||
|
|
||||||
|
setIsClearing(true);
|
||||||
|
try {
|
||||||
|
const success = await clearCache();
|
||||||
|
if (success) {
|
||||||
|
message.success('Cache cleared successfully');
|
||||||
|
} else {
|
||||||
|
message.error('Failed to clear cache');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('Error clearing cache');
|
||||||
|
} finally {
|
||||||
|
setIsClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceUpdate = async () => {
|
||||||
|
if (!forceUpdate) return;
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await forceUpdate();
|
||||||
|
message.success('Application will reload with updates');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('Failed to update application');
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal version - just show offline status
|
||||||
|
if (minimal) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={isOffline ? 'You are offline' : 'You are online'}>
|
||||||
|
<Badge
|
||||||
|
status={isOffline ? 'error' : 'success'}
|
||||||
|
text={isOffline ? 'Offline' : 'Online'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px' }}>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
{/* Connection Status */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{isOffline ? (
|
||||||
|
<DisconnectOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
) : (
|
||||||
|
<WifiOutlined style={{ color: '#52c41a' }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
{isOffline ? 'Offline Mode' : 'Online'}
|
||||||
|
</span>
|
||||||
|
{swVersion && (
|
||||||
|
<span style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
|
v{swVersion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Information */}
|
||||||
|
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
|
{isOffline ? (
|
||||||
|
'App is running from cache. Changes will sync when online.'
|
||||||
|
) : (
|
||||||
|
'App is cached for offline use. Ready to work anywhere!'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
{showControls && swManager && (
|
||||||
|
<Space size="small">
|
||||||
|
<Tooltip title="Clear all cached data">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={isClearing}
|
||||||
|
onClick={handleClearCache}
|
||||||
|
>
|
||||||
|
Clear Cache
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Check for updates and reload">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={isUpdating}
|
||||||
|
onClick={handleForceUpdate}
|
||||||
|
>
|
||||||
|
Update App
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceWorkerStatus;
|
||||||
52
worklenz-frontend/src/hooks/useAuthStatus.ts
Normal file
52
worklenz-frontend/src/hooks/useAuthStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||||
|
|
||||||
|
export const useAuthStatus = () => {
|
||||||
|
const authService = useAuthService();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const status = useMemo(() => {
|
||||||
|
try {
|
||||||
|
if (!authService || typeof authService.isAuthenticated !== 'function') {
|
||||||
|
return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = authService.isAuthenticated();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSession = authService.getCurrentSession();
|
||||||
|
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
|
||||||
|
const isAdmin = authService.isOwnerOrAdmin() && !isFreePlan;
|
||||||
|
const isSetupComplete = currentSession?.setup_completed ?? false;
|
||||||
|
|
||||||
|
const isLicenseExpired = () => {
|
||||||
|
if (!currentSession) return false;
|
||||||
|
if (currentSession.is_expired) return true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL &&
|
||||||
|
currentSession.trial_expire_date
|
||||||
|
) {
|
||||||
|
const today = new Date();
|
||||||
|
const expiryDate = new Date(currentSession.trial_expire_date);
|
||||||
|
const diffTime = today.getTime() - expiryDate.getTime();
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
return diffDays > 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isAuthenticated, isLicenseExpired: isLicenseExpired(), isAdmin, isSetupComplete };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in useAuthStatus:', error);
|
||||||
|
return { isAuthenticated: false, isLicenseExpired: false, isAdmin: false, isSetupComplete: false };
|
||||||
|
}
|
||||||
|
}, [authService]);
|
||||||
|
|
||||||
|
return { ...status, location };
|
||||||
|
};
|
||||||
@@ -1,36 +1,25 @@
|
|||||||
import { Col, ConfigProvider, Layout } from 'antd';
|
import { Col, ConfigProvider, Layout } from 'antd';
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useEffect, memo, useMemo, useCallback } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|
||||||
import Navbar from '../features/navbar/navbar';
|
import Navbar from '../features/navbar/navbar';
|
||||||
import { useAppSelector } from '../hooks/useAppSelector';
|
import { useAppSelector } from '../hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '../hooks/useAppDispatch';
|
import { useAppDispatch } from '../hooks/useAppDispatch';
|
||||||
import { colors } from '../styles/colors';
|
import { colors } from '../styles/colors';
|
||||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
|
||||||
import { useRenderPerformance } from '@/utils/performance';
|
import { useRenderPerformance } from '@/utils/performance';
|
||||||
import HubSpot from '@/components/HubSpot';
|
import HubSpot from '@/components/HubSpot';
|
||||||
|
|
||||||
const MainLayout = memo(() => {
|
const MainLayout = memo(() => {
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
|
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Performance monitoring in development
|
// Performance monitoring in development
|
||||||
useRenderPerformance('MainLayout');
|
useRenderPerformance('MainLayout');
|
||||||
|
|
||||||
// Memoize auth verification function
|
|
||||||
const verifyAuthStatus = useCallback(async () => {
|
|
||||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
|
||||||
if (!session.user.setup_completed) {
|
|
||||||
navigate('/worklenz/setup');
|
|
||||||
}
|
|
||||||
}, [dispatch, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void verifyAuthStatus();
|
|
||||||
}, [verifyAuthStatus]);
|
|
||||||
|
|
||||||
// Memoize styles to prevent object recreation on every render
|
// Memoize styles to prevent object recreation on every render
|
||||||
const headerStyles = useMemo(
|
const headerStyles = useMemo(
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ const ReportingLayout = () => {
|
|||||||
const currentSession = getCurrentSession();
|
const currentSession = getCurrentSession();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSession?.is_expired) {
|
|
||||||
navigate('/worklenz/license-expired');
|
|
||||||
}
|
|
||||||
}, [currentSession, navigate]);
|
|
||||||
|
|
||||||
// function to handle collapse
|
// function to handle collapse
|
||||||
const handleCollapsedToggler = () => {
|
const handleCollapsedToggler = () => {
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ const SettingsLayout = () => {
|
|||||||
const currentSession = getCurrentSession();
|
const currentSession = getCurrentSession();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSession?.is_expired) {
|
|
||||||
navigate('/worklenz/license-expired');
|
|
||||||
}
|
|
||||||
}, [currentSession, navigate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
|
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Flex, Typography } from 'antd';
|
import { Flex, Typography } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
|
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
|
||||||
const AdminCenterLayout: React.FC = () => {
|
const AdminCenterLayout: React.FC = () => {
|
||||||
@@ -13,9 +12,7 @@ const AdminCenterLayout: React.FC = () => {
|
|||||||
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
|
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
|
||||||
const { t } = useTranslation('admin-center/sidebar');
|
const { t } = useTranslation('admin-center/sidebar');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void dispatch(verifyAuthentication());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
259
worklenz-frontend/src/utils/README-ServiceWorker.md
Normal file
259
worklenz-frontend/src/utils/README-ServiceWorker.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# Service Worker Implementation
|
||||||
|
|
||||||
|
This directory contains the service worker implementation for Worklenz, providing offline functionality, caching, and performance improvements.
|
||||||
|
|
||||||
|
## Files Overview
|
||||||
|
|
||||||
|
- **`sw.js`** (in `/public/`) - The main service worker file
|
||||||
|
- **`serviceWorkerRegistration.ts`** - Registration and management utilities
|
||||||
|
- **`ServiceWorkerStatus.tsx`** (in `/components/`) - React component for SW status
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🔄 Caching Strategies
|
||||||
|
|
||||||
|
1. **Cache First** - Static assets (JS, CSS, images)
|
||||||
|
- Serves from cache first, falls back to network
|
||||||
|
- Perfect for unchanging resources
|
||||||
|
|
||||||
|
2. **Network First** - API requests
|
||||||
|
- Tries network first, falls back to cache
|
||||||
|
- Ensures fresh data when online
|
||||||
|
|
||||||
|
3. **Stale While Revalidate** - HTML pages
|
||||||
|
- Serves cached version immediately
|
||||||
|
- Updates cache in background
|
||||||
|
|
||||||
|
### 📱 PWA Features
|
||||||
|
|
||||||
|
- **Offline Support** - App works without internet
|
||||||
|
- **Installable** - Can be installed on devices
|
||||||
|
- **Background Sync** - Sync data when online (framework ready)
|
||||||
|
- **Push Notifications** - Real-time notifications (framework ready)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Integration
|
||||||
|
|
||||||
|
The service worker is automatically registered in `App.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { registerSW } from './utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSW({
|
||||||
|
onSuccess: (registration) => {
|
||||||
|
console.log('SW registered successfully');
|
||||||
|
},
|
||||||
|
onUpdate: (registration) => {
|
||||||
|
// Show update notification to user
|
||||||
|
},
|
||||||
|
onOfflineReady: () => {
|
||||||
|
console.log('App ready for offline use');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
const { isOffline, swManager, clearCache, forceUpdate } = useServiceWorker();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Status: {isOffline ? 'Offline' : 'Online'}</p>
|
||||||
|
<button onClick={clearCache}>Clear Cache</button>
|
||||||
|
<button onClick={forceUpdate}>Update App</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import ServiceWorkerStatus from '../components/service-worker-status/ServiceWorkerStatus';
|
||||||
|
|
||||||
|
// Minimal offline indicator
|
||||||
|
<ServiceWorkerStatus minimal />
|
||||||
|
|
||||||
|
// Full status with controls
|
||||||
|
<ServiceWorkerStatus showControls />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Cacheable Resources
|
||||||
|
|
||||||
|
Edit the patterns in `sw.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// API endpoints that can be cached
|
||||||
|
const CACHEABLE_API_PATTERNS = [
|
||||||
|
/\/api\/project-categories/,
|
||||||
|
/\/api\/task-statuses/,
|
||||||
|
// Add more patterns...
|
||||||
|
];
|
||||||
|
|
||||||
|
// Resources that should never be cached
|
||||||
|
const NEVER_CACHE_PATTERNS = [
|
||||||
|
/\/api\/auth\/login/,
|
||||||
|
/\/socket\.io/,
|
||||||
|
// Add more patterns...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Names
|
||||||
|
|
||||||
|
Update version to force cache refresh:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const CACHE_VERSION = 'v1.0.1'; // Increment when deploying
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Testing Offline
|
||||||
|
|
||||||
|
1. Open DevTools → Application → Service Workers
|
||||||
|
2. Check "Offline" to simulate offline mode
|
||||||
|
3. Verify app still functions
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check service worker status
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
console.log('SW ready:', registration);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check cache contents
|
||||||
|
caches.keys().then(names => {
|
||||||
|
console.log('Cache names:', names);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Management
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Clear all caches
|
||||||
|
caches.keys().then(names =>
|
||||||
|
Promise.all(names.map(name => caches.delete(name)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear specific cache
|
||||||
|
caches.delete('worklenz-api-v1.0.0');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Cache Strategy Selection
|
||||||
|
|
||||||
|
- **Static Assets**: Cache First (fast loading)
|
||||||
|
- **API Data**: Network First (fresh data)
|
||||||
|
- **User Content**: Network Only (always fresh)
|
||||||
|
- **App Shell**: Cache First (instant loading)
|
||||||
|
|
||||||
|
### 2. Cache Invalidation
|
||||||
|
|
||||||
|
- Increment `CACHE_VERSION` when deploying
|
||||||
|
- Use versioned URLs for assets
|
||||||
|
- Set appropriate cache headers
|
||||||
|
|
||||||
|
### 3. Offline UX
|
||||||
|
|
||||||
|
- Show offline indicators
|
||||||
|
- Queue actions for later sync
|
||||||
|
- Provide meaningful offline messages
|
||||||
|
- Cache critical user data
|
||||||
|
|
||||||
|
### 4. Performance
|
||||||
|
|
||||||
|
- Cache only necessary resources
|
||||||
|
- Set cache size limits
|
||||||
|
- Clean up old caches regularly
|
||||||
|
- Monitor cache usage
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Storage Usage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check storage quota
|
||||||
|
navigator.storage.estimate().then(estimate => {
|
||||||
|
console.log('Used:', estimate.usage);
|
||||||
|
console.log('Quota:', estimate.quota);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Hit Rate
|
||||||
|
|
||||||
|
Monitor in DevTools → Network:
|
||||||
|
- Look for "from ServiceWorker" requests
|
||||||
|
- Check cache effectiveness
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **SW not updating**
|
||||||
|
- Hard refresh (Ctrl+Shift+R)
|
||||||
|
- Clear browser cache
|
||||||
|
- Check CACHE_VERSION
|
||||||
|
|
||||||
|
2. **Resources not caching**
|
||||||
|
- Verify URL patterns
|
||||||
|
- Check NEVER_CACHE_PATTERNS
|
||||||
|
- Ensure HTTPS in production
|
||||||
|
|
||||||
|
3. **Offline features not working**
|
||||||
|
- Verify SW registration
|
||||||
|
- Check browser support
|
||||||
|
- Test cache strategies
|
||||||
|
|
||||||
|
### Reset Service Worker
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Unregister and reload
|
||||||
|
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||||
|
registrations.forEach(registration => registration.unregister());
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- ✅ Chrome 45+
|
||||||
|
- ✅ Firefox 44+
|
||||||
|
- ✅ Safari 11.1+
|
||||||
|
- ✅ Edge 17+
|
||||||
|
- ❌ Internet Explorer
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Background Sync**
|
||||||
|
- Queue offline actions
|
||||||
|
- Sync when online
|
||||||
|
|
||||||
|
2. **Push Notifications**
|
||||||
|
- Task assignments
|
||||||
|
- Project updates
|
||||||
|
- Deadline reminders
|
||||||
|
|
||||||
|
3. **Advanced Caching**
|
||||||
|
- Intelligent prefetching
|
||||||
|
- ML-based cache eviction
|
||||||
|
- Compression
|
||||||
|
|
||||||
|
4. **Offline Analytics**
|
||||||
|
- Track offline usage
|
||||||
|
- Cache hit rates
|
||||||
|
- Performance metrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: January 2025*
|
||||||
273
worklenz-frontend/src/utils/serviceWorkerRegistration.ts
Normal file
273
worklenz-frontend/src/utils/serviceWorkerRegistration.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// Service Worker Registration Utility
|
||||||
|
// Handles registration, updates, and error handling
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
window.location.hostname === '[::1]' ||
|
||||||
|
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||||
|
);
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onOfflineReady?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerSW(config?: Config) {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
// Only register in production or when explicitly testing
|
||||||
|
const swUrl = '/sw.js';
|
||||||
|
|
||||||
|
if (isLocalhost) {
|
||||||
|
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl, config);
|
||||||
|
|
||||||
|
// Add some additional logging to localhost, pointing developers to the
|
||||||
|
// service worker/PWA documentation.
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
console.log(
|
||||||
|
'This web app is being served cache-first by a service ' +
|
||||||
|
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Is not localhost. Just register service worker
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Service workers are not supported in this browser.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl: string, config?: Config) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then(registration => {
|
||||||
|
console.log('Service Worker registered successfully:', registration);
|
||||||
|
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the updated precached content has been fetched,
|
||||||
|
// but the previous service worker will still serve the older
|
||||||
|
// content until all client tabs are closed.
|
||||||
|
console.log(
|
||||||
|
'New content is available and will be used when all ' +
|
||||||
|
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onUpdate) {
|
||||||
|
config.onUpdate(registration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log('Content is cached for offline use.');
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onSuccess) {
|
||||||
|
config.onSuccess(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config && config.onOfflineReady) {
|
||||||
|
config.onOfflineReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error during service worker registration:', error);
|
||||||
|
if (config && config.onError) {
|
||||||
|
config.onError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl, {
|
||||||
|
headers: { 'Service-Worker': 'script' },
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
'No internet connection found. App is running in offline mode.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterSW() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then(registration => {
|
||||||
|
registration.unregister();
|
||||||
|
console.log('Service Worker unregistered successfully');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error during service worker unregistration:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility to communicate with service worker
|
||||||
|
export class ServiceWorkerManager {
|
||||||
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
|
|
||||||
|
constructor(registration?: ServiceWorkerRegistration) {
|
||||||
|
this.registration = registration || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message to service worker
|
||||||
|
async sendMessage(type: string, payload?: any): Promise<any> {
|
||||||
|
if (!this.registration || !this.registration.active) {
|
||||||
|
throw new Error('Service Worker not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const messageChannel = new MessageChannel();
|
||||||
|
|
||||||
|
messageChannel.port1.onmessage = (event) => {
|
||||||
|
if (event.data.error) {
|
||||||
|
reject(event.data.error);
|
||||||
|
} else {
|
||||||
|
resolve(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.registration!.active!.postMessage(
|
||||||
|
{ type, payload },
|
||||||
|
[messageChannel.port2]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Timeout after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Service Worker message timeout'));
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service worker version
|
||||||
|
async getVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await this.sendMessage('GET_VERSION');
|
||||||
|
return response.version;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get service worker version:', error);
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all caches
|
||||||
|
async clearCache(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.sendMessage('CLEAR_CACHE');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear cache:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force update service worker
|
||||||
|
async forceUpdate(): Promise<void> {
|
||||||
|
if (!this.registration) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.registration.update();
|
||||||
|
await this.sendMessage('SKIP_WAITING');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to force update service worker:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if app is running offline
|
||||||
|
isOffline(): boolean {
|
||||||
|
return !navigator.onLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache storage estimate
|
||||||
|
async getCacheSize(): Promise<StorageEstimate | null> {
|
||||||
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||||
|
try {
|
||||||
|
return await navigator.storage.estimate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get storage estimate:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to use service worker in React components
|
||||||
|
export function useServiceWorker() {
|
||||||
|
const [isOffline, setIsOffline] = React.useState(!navigator.onLine);
|
||||||
|
const [swManager, setSWManager] = React.useState<ServiceWorkerManager | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
// Register service worker
|
||||||
|
registerSW({
|
||||||
|
onSuccess: (registration) => {
|
||||||
|
setSWManager(new ServiceWorkerManager(registration));
|
||||||
|
},
|
||||||
|
onUpdate: (registration) => {
|
||||||
|
// You could show a toast here asking user to refresh
|
||||||
|
console.log('New version available');
|
||||||
|
setSWManager(new ServiceWorkerManager(registration));
|
||||||
|
},
|
||||||
|
onOfflineReady: () => {
|
||||||
|
console.log('App ready for offline use');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOffline,
|
||||||
|
swManager,
|
||||||
|
clearCache: () => swManager?.clearCache(),
|
||||||
|
forceUpdate: () => swManager?.forceUpdate(),
|
||||||
|
getVersion: () => swManager?.getVersion(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -138,5 +138,8 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
define: {
|
define: {
|
||||||
__DEV__: !isProduction,
|
__DEV__: !isProduction,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// **Public Directory** - sw.js will be automatically copied from public/ to build/
|
||||||
|
publicDir: 'public',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user