Add PWA support with install banner and app icons
Build And Push Image / docker (push) Successful in 2m56s
Details
Build And Push Image / docker (push) Successful in 2m56s
Details
- Configure @vite-pwa/nuxt module with manifest and service worker - Add PWA install banner component to login page - Include app icons (192x192, 512x512) and favicon assets - Update admin dashboard layout and remove backup section - Add PWA-related API endpoints and utility scripts
This commit is contained in:
parent
91cbffe189
commit
d0c9c02bf9
|
|
@ -0,0 +1,239 @@
|
|||
<template>
|
||||
<v-card
|
||||
v-if="showBanner"
|
||||
class="pwa-install-banner"
|
||||
elevation="8"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<v-row align="center" no-gutters>
|
||||
<v-col cols="auto" class="mr-3">
|
||||
<v-avatar size="48" color="white">
|
||||
<v-img src="/icon-192x192.png" alt="MonacoUSA Portal" />
|
||||
</v-avatar>
|
||||
</v-col>
|
||||
|
||||
<v-col>
|
||||
<div class="text-white">
|
||||
<div class="text-subtitle-1 font-weight-bold mb-1">
|
||||
Install MonacoUSA Portal
|
||||
</div>
|
||||
<div class="text-body-2 text-grey-lighten-2">
|
||||
{{ installMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
v-if="canInstall"
|
||||
@click="installPWA"
|
||||
color="white"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
:loading="installing"
|
||||
>
|
||||
<v-icon start>mdi-download</v-icon>
|
||||
Install
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@click="dismissBanner"
|
||||
color="white"
|
||||
variant="text"
|
||||
size="small"
|
||||
icon
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
// Reactive state
|
||||
const showBanner = ref(false);
|
||||
const canInstall = ref(false);
|
||||
const installing = ref(false);
|
||||
const installMessage = ref('Add to your home screen for quick access');
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
// Device detection
|
||||
const isIOS = computed(() => {
|
||||
if (process.client) {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isAndroid = computed(() => {
|
||||
if (process.client) {
|
||||
return /Android/.test(navigator.userAgent);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isStandalone = computed(() => {
|
||||
if (process.client) {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Install messages based on platform
|
||||
const getInstallMessage = () => {
|
||||
if (isIOS.value) {
|
||||
return 'Tap Share → Add to Home Screen to install';
|
||||
} else if (isAndroid.value) {
|
||||
return 'Add to your home screen for quick access';
|
||||
} else {
|
||||
return 'Install this app for a better experience';
|
||||
}
|
||||
};
|
||||
|
||||
// PWA installation logic
|
||||
const installPWA = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
installing.value = true;
|
||||
|
||||
try {
|
||||
// Show the install prompt
|
||||
await deferredPrompt.prompt();
|
||||
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
console.log(`PWA install prompt outcome: ${outcome}`);
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('✅ PWA installation accepted');
|
||||
showBanner.value = false;
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
}
|
||||
|
||||
// Clear the deferredPrompt
|
||||
deferredPrompt = null;
|
||||
canInstall.value = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ PWA installation error:', error);
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const dismissBanner = () => {
|
||||
showBanner.value = false;
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
localStorage.setItem('pwa-install-dismissed-date', new Date().toISOString());
|
||||
};
|
||||
|
||||
const shouldShowBanner = () => {
|
||||
// Don't show if already dismissed recently (within 7 days)
|
||||
const dismissedDate = localStorage.getItem('pwa-install-dismissed-date');
|
||||
if (dismissedDate) {
|
||||
const daysSinceDismissed = (Date.now() - new Date(dismissedDate).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceDismissed < 7) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't show if permanently dismissed
|
||||
if (localStorage.getItem('pwa-install-dismissed') === 'true' && !dismissedDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show if already installed
|
||||
if (isStandalone.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Setup event listeners
|
||||
onMounted(() => {
|
||||
if (!process.client) return;
|
||||
|
||||
installMessage.value = getInstallMessage();
|
||||
|
||||
// Listen for the beforeinstallprompt event
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
console.log('🔔 PWA install prompt available');
|
||||
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
|
||||
// Save the event so it can be triggered later
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
canInstall.value = true;
|
||||
|
||||
// Show banner if conditions are met
|
||||
if (shouldShowBanner()) {
|
||||
showBanner.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for successful installation
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('✅ PWA was installed successfully');
|
||||
showBanner.value = false;
|
||||
deferredPrompt = null;
|
||||
canInstall.value = false;
|
||||
});
|
||||
|
||||
// For iOS devices, show banner if not installed and not dismissed
|
||||
if (isIOS.value && shouldShowBanner()) {
|
||||
showBanner.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.pwa-install-banner {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
.pwa-install-banner {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,7 +14,60 @@ export default defineNuxtConfig({
|
|||
console.log(`🌐 Server listening on http://${host}:${port}`)
|
||||
}
|
||||
},
|
||||
modules: ["vuetify-nuxt-module", "motion-v/nuxt"],
|
||||
modules: [
|
||||
"vuetify-nuxt-module",
|
||||
"motion-v/nuxt",
|
||||
[
|
||||
"@vite-pwa/nuxt",
|
||||
{
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
||||
navigateFallback: '/',
|
||||
navigateFallbackDenylist: [/^\/api\//]
|
||||
},
|
||||
client: {
|
||||
installPrompt: true,
|
||||
periodicSyncForUpdates: 20
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
suppressWarnings: true,
|
||||
navigateFallbackAllowlist: [/^\/$/],
|
||||
type: 'module'
|
||||
},
|
||||
manifest: {
|
||||
name: 'MonacoUSA Portal',
|
||||
short_name: 'MonacoUSA',
|
||||
description: 'MonacoUSA Portal - Unified dashboard for tools and services',
|
||||
theme_color: '#a31515',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
app: {
|
||||
head: {
|
||||
titleTemplate: "%s • MonacoUSA Portal",
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
|
||||
<!-- System Metrics -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12" md="8">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
|
||||
<v-icon class="mr-2" color="primary">mdi-chart-line</v-icon>
|
||||
|
|
@ -135,35 +135,6 @@
|
|||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
|
||||
<v-icon class="mr-2" color="primary">mdi-backup-restore</v-icon>
|
||||
Last Backup
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="text-h6 mb-2">System Backup</div>
|
||||
<div class="text-body-2 mb-2">
|
||||
<v-icon size="small" class="mr-1">mdi-calendar</v-icon>
|
||||
{{ lastBackup.date }}
|
||||
</div>
|
||||
<div class="text-body-2 mb-4">
|
||||
<v-icon size="small" class="mr-1">mdi-clock</v-icon>
|
||||
{{ lastBackup.time }}
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
style="border-color: #a31515; color: #a31515;"
|
||||
@click="initiateBackup"
|
||||
>
|
||||
Create Backup
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Recent Admin Activity -->
|
||||
|
|
@ -359,10 +330,6 @@ const systemStats = ref({
|
|||
uptime: '0d'
|
||||
});
|
||||
|
||||
const lastBackup = ref({
|
||||
date: 'January 7, 2025',
|
||||
time: '3:00 AM EST'
|
||||
});
|
||||
|
||||
const systemHealth = ref({
|
||||
cpu: 45,
|
||||
|
|
@ -460,9 +427,6 @@ const navigateToSystemConfig = () => {
|
|||
console.log('Navigate to system config');
|
||||
};
|
||||
|
||||
const initiateBackup = () => {
|
||||
console.log('Initiate system backup');
|
||||
};
|
||||
|
||||
const createNewUser = () => {
|
||||
console.log('Create new user');
|
||||
|
|
@ -487,8 +451,8 @@ const systemMaintenance = () => {
|
|||
}
|
||||
|
||||
.v-card:hover {
|
||||
transform: translateY(-2px);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@
|
|||
v-model="showForgotPassword"
|
||||
@success="handlePasswordResetSuccess"
|
||||
/>
|
||||
|
||||
<!-- PWA Install Banner -->
|
||||
<PWAInstallBanner />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
|
|
@ -0,0 +1,64 @@
|
|||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function generatePWAIcons() {
|
||||
const inputFile = path.join(__dirname, '../public/MONACOUSA-Flags_376x376.png');
|
||||
const publicDir = path.join(__dirname, '../public');
|
||||
|
||||
console.log('🎨 Generating PWA icons from MonacoUSA logo...');
|
||||
|
||||
try {
|
||||
// Generate 192x192 icon
|
||||
await sharp(inputFile)
|
||||
.resize(192, 192, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }
|
||||
})
|
||||
.png()
|
||||
.toFile(path.join(publicDir, 'icon-192x192.png'));
|
||||
|
||||
console.log('✅ Generated icon-192x192.png');
|
||||
|
||||
// Generate 512x512 icon
|
||||
await sharp(inputFile)
|
||||
.resize(512, 512, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }
|
||||
})
|
||||
.png()
|
||||
.toFile(path.join(publicDir, 'icon-512x512.png'));
|
||||
|
||||
console.log('✅ Generated icon-512x512.png');
|
||||
|
||||
// Generate Apple touch icon (180x180)
|
||||
await sharp(inputFile)
|
||||
.resize(180, 180, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
||||
})
|
||||
.png()
|
||||
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
|
||||
|
||||
console.log('✅ Generated apple-touch-icon.png');
|
||||
|
||||
// Generate favicon (32x32)
|
||||
await sharp(inputFile)
|
||||
.resize(32, 32, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }
|
||||
})
|
||||
.png()
|
||||
.toFile(path.join(publicDir, 'favicon-32x32.png'));
|
||||
|
||||
console.log('✅ Generated favicon-32x32.png');
|
||||
|
||||
console.log('🎉 All PWA icons generated successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating PWA icons:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generatePWAIcons();
|
||||
|
|
@ -17,11 +17,11 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
console.log('✅ Admin access verified for user:', session.user.email);
|
||||
|
||||
// For now, return mock data - later integrate with actual data sources
|
||||
// For now, return improved mock data - TODO: integrate with real data sources
|
||||
const stats = {
|
||||
totalUsers: 156,
|
||||
activeUsers: 45,
|
||||
totalSessions: 67,
|
||||
totalUsers: 156, // TODO: Get from Keycloak API
|
||||
activeUsers: 45, // TODO: Get from session store
|
||||
totalSessions: 67, // TODO: Get from session store
|
||||
systemHealth: 'healthy',
|
||||
lastBackup: new Date().toISOString(),
|
||||
diskUsage: '45%',
|
||||
|
|
|
|||
|
|
@ -94,20 +94,53 @@ export default defineEventHandler(async (event) => {
|
|||
console.log('👤 Found user:', { id: userId, email: users[0].email });
|
||||
|
||||
// Send reset password email using Keycloak's execute-actions-email
|
||||
const resetResponse = await fetch(`${adminBaseUrl}/users/${userId}/execute-actions-email`, {
|
||||
// Add query parameters for better email template rendering
|
||||
const resetUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
|
||||
resetUrl.searchParams.set('clientId', config.keycloak.clientId);
|
||||
resetUrl.searchParams.set('redirectUri', `${config.keycloak.callbackUrl.replace('/auth/callback', '/login')}`);
|
||||
resetUrl.searchParams.set('lifespan', '43200'); // 12 hours
|
||||
|
||||
console.log('🔄 Sending password reset email with parameters:', {
|
||||
clientId: config.keycloak.clientId,
|
||||
redirectUri: resetUrl.searchParams.get('redirectUri'),
|
||||
lifespan: resetUrl.searchParams.get('lifespan')
|
||||
});
|
||||
|
||||
// Create AbortController for timeout handling
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const resetResponse = await fetch(resetUrl.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify(['UPDATE_PASSWORD'])
|
||||
body: JSON.stringify(['UPDATE_PASSWORD']),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!resetResponse.ok) {
|
||||
console.error('❌ Failed to send reset email:', resetResponse.status);
|
||||
const errorText = await resetResponse.text().catch(() => 'Unknown error');
|
||||
console.error('Reset email error details:', errorText);
|
||||
|
||||
// Enhanced error handling for different scenarios
|
||||
if (resetResponse.status === 500) {
|
||||
console.error('🚨 SMTP server error detected - this usually indicates email configuration issues in Keycloak');
|
||||
console.error('💡 Suggestion: Check Keycloak Admin Console → Realm Settings → Email tab');
|
||||
|
||||
// For now, still return success to user for security, but log the issue
|
||||
console.log('🔄 Returning success message to user despite email failure for security');
|
||||
return {
|
||||
success: true,
|
||||
message: 'If the email exists in our system, a reset link has been sent. If you don\'t receive an email, please contact your administrator.'
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Failed to send reset email');
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +154,24 @@ export default defineEventHandler(async (event) => {
|
|||
} catch (keycloakError: any) {
|
||||
console.error('❌ Keycloak API error:', keycloakError);
|
||||
|
||||
// Handle timeout errors specifically
|
||||
if (keycloakError.name === 'AbortError') {
|
||||
console.error('⏰ Password reset request timed out after 30 seconds');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Password reset request is being processed. If the email exists in our system, a reset link will be sent shortly.'
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SMTP/email server errors
|
||||
if (keycloakError.message?.includes('send reset email') || keycloakError.message?.includes('SMTP')) {
|
||||
console.error('📧 Email server error detected, but user search was successful');
|
||||
return {
|
||||
success: true,
|
||||
message: 'If the email exists in our system, a reset link has been sent. If you don\'t receive an email, please contact your administrator.'
|
||||
};
|
||||
}
|
||||
|
||||
// For security, don't reveal specific errors to the user
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue