Add email verification system for user registration
Build And Push Image / docker (push) Successful in 3m1s
Details
Build And Push Image / docker (push) Successful in 3m1s
Details
- Add SMTP configuration UI in admin panel with test functionality - Implement email verification workflow with tokens and templates - Add verification success/expired pages for user feedback - Include nodemailer, handlebars, and JWT dependencies - Create API endpoints for email config, testing, and verification
This commit is contained in:
parent
7b72d7a565
commit
4ec05e29dc
|
|
@ -35,6 +35,10 @@
|
||||||
<v-icon start>mdi-account-plus</v-icon>
|
<v-icon start>mdi-account-plus</v-icon>
|
||||||
Registration
|
Registration
|
||||||
</v-tab>
|
</v-tab>
|
||||||
|
<v-tab value="email">
|
||||||
|
<v-icon start>mdi-email</v-icon>
|
||||||
|
Email
|
||||||
|
</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<v-card-text class="pa-6" style="min-height: 400px;">
|
<v-card-text class="pa-6" style="min-height: 400px;">
|
||||||
|
|
@ -281,6 +285,177 @@
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
|
|
||||||
|
<!-- Email Configuration Tab -->
|
||||||
|
<v-tabs-window-item value="email">
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template #title>SMTP Email Configuration</template>
|
||||||
|
Configure SMTP settings for sending emails from the portal (registration confirmations, password resets, dues reminders).
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-form ref="emailFormRef" v-model="emailFormValid">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="emailForm.host"
|
||||||
|
label="SMTP Host"
|
||||||
|
variant="outlined"
|
||||||
|
:rules="[rules.required]"
|
||||||
|
required
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
hint="Your SMTP server hostname"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="emailForm.port"
|
||||||
|
label="Port"
|
||||||
|
variant="outlined"
|
||||||
|
:rules="[rules.required, rules.validPort]"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
placeholder="587"
|
||||||
|
hint="Usually 587 (TLS) or 465 (SSL)"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-switch
|
||||||
|
v-model="emailForm.secure"
|
||||||
|
label="Use SSL/TLS encryption"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
Enable for secure connection (recommended for production)
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="emailForm.username"
|
||||||
|
label="Username"
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="your-email@domain.com"
|
||||||
|
hint="SMTP authentication username (usually your email)"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="emailForm.password"
|
||||||
|
label="Password"
|
||||||
|
variant="outlined"
|
||||||
|
:type="showEmailPassword ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="showEmailPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
|
@click:append-inner="showEmailPassword = !showEmailPassword"
|
||||||
|
placeholder="Enter SMTP password"
|
||||||
|
hint="SMTP authentication password or app password"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="emailForm.fromAddress"
|
||||||
|
label="From Email Address"
|
||||||
|
variant="outlined"
|
||||||
|
:rules="[rules.required, rules.email]"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
placeholder="noreply@monacousa.org"
|
||||||
|
hint="Email address that emails will be sent from"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="emailForm.fromName"
|
||||||
|
label="From Name"
|
||||||
|
variant="outlined"
|
||||||
|
:rules="[rules.required]"
|
||||||
|
required
|
||||||
|
placeholder="MonacoUSA Portal"
|
||||||
|
hint="Display name for outgoing emails"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="testEmailAddress"
|
||||||
|
label="Test Email Address"
|
||||||
|
variant="outlined"
|
||||||
|
:rules="[rules.email]"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@monacousa.org"
|
||||||
|
hint="Email address to send test email to"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-btn
|
||||||
|
@click="sendTestEmail"
|
||||||
|
:loading="emailTestLoading"
|
||||||
|
:disabled="!emailFormValid || emailLoading || !testEmailAddress"
|
||||||
|
color="info"
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-email-send</v-icon>
|
||||||
|
Send Test Email
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<div class="d-flex align-center h-100">
|
||||||
|
<v-chip
|
||||||
|
v-if="emailTestStatus"
|
||||||
|
:color="emailTestStatus.success ? 'success' : 'error'"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<v-icon start size="14">
|
||||||
|
{{ emailTestStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
|
||||||
|
</v-icon>
|
||||||
|
{{ emailTestStatus.message }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-alert
|
||||||
|
type="warning"
|
||||||
|
variant="outlined"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<div class="text-body-2">
|
||||||
|
<strong>Common SMTP Providers:</strong>
|
||||||
|
<ul class="mt-2 ml-4">
|
||||||
|
<li><strong>Gmail:</strong> smtp.gmail.com:587 (use App Password, not regular password)</li>
|
||||||
|
<li><strong>SendGrid:</strong> smtp.sendgrid.net:587</li>
|
||||||
|
<li><strong>Amazon SES:</strong> email-smtp.region.amazonaws.com:587</li>
|
||||||
|
<li><strong>Mailgun:</strong> smtp.mailgun.org:587</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-form>
|
||||||
|
</v-tabs-window-item>
|
||||||
</v-tabs-window>
|
</v-tabs-window>
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
|
|
@ -332,7 +507,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NocoDBSettings, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
|
import type { NocoDBSettings, RecaptchaConfig, RegistrationConfig, SMTPConfig } from '~/utils/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
|
|
@ -353,25 +528,34 @@ const activeTab = ref('nocodb');
|
||||||
const nocodbFormRef = ref();
|
const nocodbFormRef = ref();
|
||||||
const recaptchaFormRef = ref();
|
const recaptchaFormRef = ref();
|
||||||
const registrationFormRef = ref();
|
const registrationFormRef = ref();
|
||||||
|
const emailFormRef = ref();
|
||||||
|
|
||||||
// Form validity
|
// Form validity
|
||||||
const nocodbFormValid = ref(false);
|
const nocodbFormValid = ref(false);
|
||||||
const recaptchaFormValid = ref(false);
|
const recaptchaFormValid = ref(false);
|
||||||
const registrationFormValid = ref(false);
|
const registrationFormValid = ref(false);
|
||||||
|
const emailFormValid = ref(false);
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
const nocodbLoading = ref(false);
|
const nocodbLoading = ref(false);
|
||||||
const recaptchaLoading = ref(false);
|
const recaptchaLoading = ref(false);
|
||||||
const registrationLoading = ref(false);
|
const registrationLoading = ref(false);
|
||||||
|
const emailLoading = ref(false);
|
||||||
const nocodbTestLoading = ref(false);
|
const nocodbTestLoading = ref(false);
|
||||||
|
const emailTestLoading = ref(false);
|
||||||
|
|
||||||
// Display states
|
// Display states
|
||||||
const showNocodbApiKey = ref(false);
|
const showNocodbApiKey = ref(false);
|
||||||
const showRecaptchaSecret = ref(false);
|
const showRecaptchaSecret = ref(false);
|
||||||
|
const showEmailPassword = ref(false);
|
||||||
const showSuccessMessage = ref(false);
|
const showSuccessMessage = ref(false);
|
||||||
const successMessage = ref('');
|
const successMessage = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const nocodbConnectionStatus = ref<{ success: boolean; message: string } | null>(null);
|
const nocodbConnectionStatus = ref<{ success: boolean; message: string } | null>(null);
|
||||||
|
const emailTestStatus = ref<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Test email address
|
||||||
|
const testEmailAddress = ref('');
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
const nocodbForm = ref<NocoDBSettings>({
|
const nocodbForm = ref<NocoDBSettings>({
|
||||||
|
|
@ -392,6 +576,16 @@ const registrationForm = ref<RegistrationConfig>({
|
||||||
accountHolder: ''
|
accountHolder: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emailForm = ref<SMTPConfig>({
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
fromAddress: '',
|
||||||
|
fromName: 'MonacoUSA Portal'
|
||||||
|
});
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: string | number) => {
|
required: (value: string | number) => {
|
||||||
|
|
@ -405,6 +599,14 @@ const rules = {
|
||||||
positiveNumber: (value: number) => {
|
positiveNumber: (value: number) => {
|
||||||
return (value && value > 0) || 'Must be a positive number';
|
return (value && value > 0) || 'Must be a positive number';
|
||||||
},
|
},
|
||||||
|
validPort: (value: number) => {
|
||||||
|
return (value && value >= 1 && value <= 65535) || 'Port must be between 1 and 65535';
|
||||||
|
},
|
||||||
|
email: (value: string) => {
|
||||||
|
if (!value) return true;
|
||||||
|
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return pattern.test(value) || 'Please enter a valid email address';
|
||||||
|
},
|
||||||
iban: (value: string) => {
|
iban: (value: string) => {
|
||||||
if (!value) return true;
|
if (!value) return true;
|
||||||
// Basic IBAN validation (length and format)
|
// Basic IBAN validation (length and format)
|
||||||
|
|
@ -426,6 +628,8 @@ const isCurrentTabValid = computed(() => {
|
||||||
return recaptchaFormValid.value;
|
return recaptchaFormValid.value;
|
||||||
case 'registration':
|
case 'registration':
|
||||||
return registrationFormValid.value;
|
return registrationFormValid.value;
|
||||||
|
case 'email':
|
||||||
|
return emailFormValid.value;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -439,6 +643,8 @@ const getCurrentTabName = computed(() => {
|
||||||
return 'reCAPTCHA Settings';
|
return 'reCAPTCHA Settings';
|
||||||
case 'registration':
|
case 'registration':
|
||||||
return 'Registration Settings';
|
return 'Registration Settings';
|
||||||
|
case 'email':
|
||||||
|
return 'Email Settings';
|
||||||
default:
|
default:
|
||||||
return 'Settings';
|
return 'Settings';
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +709,53 @@ const testNocodbConnection = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
if (!emailFormRef.value || !testEmailAddress.value) return;
|
||||||
|
|
||||||
|
const isValid = await emailFormRef.value.validate();
|
||||||
|
if (!isValid.valid) return;
|
||||||
|
|
||||||
|
// First save email configuration
|
||||||
|
emailLoading.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch('/api/admin/smtp-config', {
|
||||||
|
method: 'POST',
|
||||||
|
body: emailForm.value
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save SMTP config:', error);
|
||||||
|
errorMessage.value = 'Failed to save SMTP configuration: ' + error.message;
|
||||||
|
emailLoading.value = false;
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
emailLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then send test email
|
||||||
|
emailTestLoading.value = true;
|
||||||
|
emailTestStatus.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/test-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { testEmail: testEmailAddress.value }
|
||||||
|
});
|
||||||
|
|
||||||
|
emailTestStatus.value = {
|
||||||
|
success: response.success,
|
||||||
|
message: response.message || (response.success ? 'Test email sent successfully' : 'Test email failed')
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
emailTestStatus.value = {
|
||||||
|
success: false,
|
||||||
|
message: error.message || 'Test email failed'
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
emailTestLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Save current tab
|
// Save current tab
|
||||||
const saveCurrentTab = async () => {
|
const saveCurrentTab = async () => {
|
||||||
let formRef, loading, data, endpoint;
|
let formRef, loading, data, endpoint;
|
||||||
|
|
@ -526,6 +779,12 @@ const saveCurrentTab = async () => {
|
||||||
data = registrationForm.value;
|
data = registrationForm.value;
|
||||||
endpoint = '/api/admin/registration-config';
|
endpoint = '/api/admin/registration-config';
|
||||||
break;
|
break;
|
||||||
|
case 'email':
|
||||||
|
formRef = emailFormRef.value;
|
||||||
|
loading = emailLoading;
|
||||||
|
data = emailForm.value;
|
||||||
|
endpoint = '/api/admin/smtp-config';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,13 @@
|
||||||
<v-menu
|
<v-menu
|
||||||
v-model="dropdownOpen"
|
v-model="dropdownOpen"
|
||||||
:close-on-content-click="false"
|
:close-on-content-click="false"
|
||||||
location="bottom start"
|
:location="isMobile ? undefined : 'bottom start'"
|
||||||
:offset="isMobile ? 8 : 4"
|
:offset="isMobile ? 0 : 4"
|
||||||
min-width="280"
|
:min-width="isMobile ? undefined : '280'"
|
||||||
:transition="isMobile ? 'slide-y-transition' : 'fade-transition'"
|
:transition="isMobile ? 'fade-transition' : 'fade-transition'"
|
||||||
|
:no-click-animation="isMobile"
|
||||||
|
:attach="isMobile"
|
||||||
|
:strategy="isMobile ? 'fixed' : 'absolute'"
|
||||||
>
|
>
|
||||||
<template #activator="{ props: menuProps }">
|
<template #activator="{ props: menuProps }">
|
||||||
<div
|
<div
|
||||||
|
|
@ -498,8 +501,9 @@ watch(() => props.modelValue, (newValue) => {
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
z-index: 999;
|
z-index: 1999;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Header */
|
/* Mobile Header */
|
||||||
|
|
@ -553,15 +557,17 @@ watch(() => props.modelValue, (newValue) => {
|
||||||
|
|
||||||
.country-dropdown--mobile {
|
.country-dropdown--mobile {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 50% !important;
|
top: 10% !important;
|
||||||
left: 50% !important;
|
left: 5% !important;
|
||||||
transform: translate(-50%, -50%) !important;
|
right: 5% !important;
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 400px !important;
|
max-width: 400px !important;
|
||||||
max-height: 80vh !important;
|
max-height: 80vh !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
z-index: 1000 !important;
|
z-index: 2000 !important;
|
||||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2) !important;
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-list--mobile {
|
.country-list--mobile {
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,32 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "^3.2.0",
|
"@nuxt/ui": "^3.2.0",
|
||||||
|
"@types/handlebars": "^4.0.40",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@vite-pwa/nuxt": "^0.10.8",
|
"@vite-pwa/nuxt": "^0.10.8",
|
||||||
"base-vue-phone-input": "^0.1.13",
|
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"libphonenumber-js": "^1.12.10",
|
"libphonenumber-js": "^1.12.10",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"motion-v": "^1.6.1",
|
"nodemailer": "^7.0.5",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"systeminformation": "^5.27.7",
|
"systeminformation": "^5.27.7",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-country-flag-next": "^2.3.2",
|
"vue-country-flag-next": "^2.3.2",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
"vue-tel-input": "^9.3.0",
|
|
||||||
"vuetify-nuxt-module": "^0.18.3"
|
"vuetify-nuxt-module": "^0.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/formidable": "^3.4.5",
|
"@types/formidable": "^3.4.5",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0"
|
||||||
"@types/vue-tel-input": "^2.1.7"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
@ -6104,6 +6106,22 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/handlebars": {
|
||||||
|
"version": "4.0.40",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.0.40.tgz",
|
||||||
|
"integrity": "sha512-sGWNtsjNrLOdKha2RV1UeF8+UbQnPSG7qbe5wwbni0mw4h2gHXyPFUMOC+xwGirIiiydM/HSqjDO4rk6NFB18w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mime-types": {
|
"node_modules/@types/mime-types": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
|
|
@ -6111,16 +6129,30 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.9",
|
"version": "20.19.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
|
||||||
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
|
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "6.4.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||||
|
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/normalize-package-data": {
|
"node_modules/@types/normalize-package-data": {
|
||||||
"version": "2.4.4",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
|
||||||
|
|
@ -6151,52 +6183,6 @@
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/vue-tel-input": {
|
|
||||||
"version": "2.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/vue-tel-input/-/vue-tel-input-2.1.7.tgz",
|
|
||||||
"integrity": "sha512-s7bj9VBEQwfSaHDfWJ1KnQ+bgx0N+GT+y3pcsvoEqOZv4zJn/DX3CxNWnyBOQx8hwt3fGWp/aTm/X+fbq91Uiw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"vue": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/vue-tel-input/node_modules/@vue/compiler-sfc": {
|
|
||||||
"version": "2.7.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz",
|
|
||||||
"integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/parser": "^7.23.5",
|
|
||||||
"postcss": "^8.4.14",
|
|
||||||
"source-map": "^0.6.1"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"prettier": "^1.18.2 || ^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/vue-tel-input/node_modules/source-map": {
|
|
||||||
"version": "0.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
|
||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/vue-tel-input/node_modules/vue": {
|
|
||||||
"version": "2.7.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz",
|
|
||||||
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
|
|
||||||
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vue/compiler-sfc": "2.7.16",
|
|
||||||
"csstype": "^3.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.21",
|
"version": "0.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||||
|
|
@ -7359,17 +7345,6 @@
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/base-vue-phone-input": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/base-vue-phone-input/-/base-vue-phone-input-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-e3CH2cI/ddnAAv4+cBBiCmU/VVAcfhWjzBGlJMpte1d2xE/ASmENO72zwulHikLkywQ45lYBWkBQwSxMPYfUXA==",
|
|
||||||
"dependencies": {
|
|
||||||
"libphonenumber-js": "^1.11.7"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vue": "^3.4.37"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
|
@ -7556,6 +7531,12 @@
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
|
@ -8951,6 +8932,15 @@
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
|
@ -9886,33 +9876,6 @@
|
||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
|
||||||
"version": "12.23.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz",
|
|
||||||
"integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"motion-dom": "^12.23.12",
|
|
||||||
"motion-utils": "^12.23.6",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emotion/is-prop-valid": "*",
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@emotion/is-prop-valid": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
|
@ -10333,6 +10296,36 @@
|
||||||
"uncrypto": "^0.1.3"
|
"uncrypto": "^0.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/handlebars": {
|
||||||
|
"version": "4.7.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||||
|
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.5",
|
||||||
|
"neo-async": "^2.6.2",
|
||||||
|
"source-map": "^0.6.1",
|
||||||
|
"wordwrap": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"handlebars": "bin/handlebars"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.7"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"uglify-js": "^3.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/handlebars/node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
|
|
@ -10420,12 +10413,6 @@
|
||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hey-listen": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/hookable": {
|
"node_modules/hookable": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
|
@ -11860,6 +11847,28 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/junk": {
|
"node_modules/junk": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz",
|
||||||
|
|
@ -11872,6 +11881,27 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwt-decode": {
|
"node_modules/jwt-decode": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
|
|
@ -12360,18 +12390,60 @@
|
||||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isarguments": {
|
"node_modules/lodash.isarguments": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
|
"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.sortby": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
|
|
@ -12753,36 +12825,6 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
|
||||||
"version": "12.23.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz",
|
|
||||||
"integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"motion-utils": "^12.23.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/motion-utils": {
|
|
||||||
"version": "12.23.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
|
||||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/motion-v": {
|
|
||||||
"version": "1.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/motion-v/-/motion-v-1.7.0.tgz",
|
|
||||||
"integrity": "sha512-5oPDF5GBpcRnIZuce7Wap09S8afH4JeBWD3VbMRg4hZKk0olQnTFuHjgQUGMpX3V1WXrZgyveoF02W51XMxx9w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"framer-motion": "12.23.12",
|
|
||||||
"hey-listen": "^1.0.8",
|
|
||||||
"motion-dom": "12.23.12"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@vueuse/core": ">=10.0.0",
|
|
||||||
"vue": ">=3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
|
|
@ -12828,6 +12870,12 @@
|
||||||
"integrity": "sha512-9ca1h0Xjvo9bEkE4UOxgAzLV0jHKe6LMaxo37ND2DAhhAtd0j8pR1Wxz+/goMrZO8AEZTWCmyaOsFI/W5AdpCQ==",
|
"integrity": "sha512-9ca1h0Xjvo9bEkE4UOxgAzLV0jHKe6LMaxo37ND2DAhhAtd0j8pR1Wxz+/goMrZO8AEZTWCmyaOsFI/W5AdpCQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/neo-async": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/netlify": {
|
"node_modules/netlify": {
|
||||||
"version": "13.3.5",
|
"version": "13.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/netlify/-/netlify-13.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/netlify/-/netlify-13.3.5.tgz",
|
||||||
|
|
@ -13084,6 +13132,15 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||||
|
|
@ -14401,23 +14458,6 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "2.8.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
|
||||||
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin-prettier.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.13.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||||
|
|
@ -16488,6 +16528,19 @@
|
||||||
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uglify-js": {
|
||||||
|
"version": "3.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
|
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"uglifyjs": "bin/uglifyjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ultrahtml": {
|
"node_modules/ultrahtml": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz",
|
||||||
|
|
@ -16557,7 +16610,6 @@
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unenv": {
|
"node_modules/unenv": {
|
||||||
|
|
@ -17762,16 +17814,6 @@
|
||||||
"vue": "^3.2.0"
|
"vue": "^3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-tel-input": {
|
|
||||||
"version": "9.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-tel-input/-/vue-tel-input-9.3.0.tgz",
|
|
||||||
"integrity": "sha512-8PgAFxO5npztMruL1O0NZxPDJScfc9Qx2mDERErRKS7XQ1l/MSk8rfi0XgPbNdOBiocdweDnikNdo3xSJrQodA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"libphonenumber-js": "^1.10.51",
|
|
||||||
"vue": "^3.5.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vuetify": {
|
"node_modules/vuetify": {
|
||||||
"version": "3.9.4",
|
"version": "3.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.9.4.tgz",
|
||||||
|
|
@ -18066,6 +18108,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wordwrap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/workbox-background-sync": {
|
"node_modules/workbox-background-sync": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,19 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "^3.2.0",
|
"@nuxt/ui": "^3.2.0",
|
||||||
|
"@types/handlebars": "^4.0.40",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@vite-pwa/nuxt": "^0.10.8",
|
"@vite-pwa/nuxt": "^0.10.8",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"libphonenumber-js": "^1.12.10",
|
"libphonenumber-js": "^1.12.10",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
|
"nodemailer": "^7.0.5",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"systeminformation": "^5.27.7",
|
"systeminformation": "^5.27.7",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
<template>
|
||||||
|
<div class="verification-expired">
|
||||||
|
<v-container class="fill-height" fluid>
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="6" lg="4">
|
||||||
|
<v-card class="elevation-12 rounded-lg">
|
||||||
|
<v-card-text class="text-center pa-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-icon
|
||||||
|
color="warning"
|
||||||
|
size="80"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
mdi-clock-alert
|
||||||
|
</v-icon>
|
||||||
|
|
||||||
|
<h1 class="text-h4 font-weight-bold text-warning mb-3">
|
||||||
|
{{ pageTitle }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-body-1 text-medium-emphasis mb-6">
|
||||||
|
{{ pageDescription }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Information alert -->
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-6 text-start"
|
||||||
|
icon="mdi-information"
|
||||||
|
>
|
||||||
|
<div class="text-body-2">
|
||||||
|
<strong>What to do next:</strong>
|
||||||
|
<ul class="mt-2 pl-4">
|
||||||
|
<li>Request a new verification email below</li>
|
||||||
|
<li>Check your spam/junk folder for emails from MonacoUSA</li>
|
||||||
|
<li>Make sure you're checking the correct email address</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resend verification form -->
|
||||||
|
<v-form @submit.prevent="resendVerification" :disabled="loading">
|
||||||
|
<div class="mb-4">
|
||||||
|
<v-text-field
|
||||||
|
v-model="email"
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
:rules="emailRules"
|
||||||
|
:error-messages="emailError"
|
||||||
|
required
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3 mb-6">
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
variant="elevated"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!email || !isValidEmail(email)"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-email-send</v-icon>
|
||||||
|
{{ loading ? 'Sending...' : 'Send New Verification Email' }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
to="/login"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-login</v-icon>
|
||||||
|
Back to Login
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
<!-- Success message -->
|
||||||
|
<v-alert
|
||||||
|
v-if="successMessage"
|
||||||
|
type="success"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
icon="mdi-check"
|
||||||
|
>
|
||||||
|
{{ successMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
icon="mdi-alert"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- Additional help -->
|
||||||
|
<div class="mt-6 pt-4 border-t">
|
||||||
|
<p class="text-caption text-medium-emphasis mb-2">
|
||||||
|
Still having trouble? Contact support:
|
||||||
|
</p>
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-icon="mdi-email"
|
||||||
|
>
|
||||||
|
support@monacousa.org
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
middleware: 'guest'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get query parameters
|
||||||
|
const route = useRoute();
|
||||||
|
const reason = computed(() => route.query.reason as string || 'expired');
|
||||||
|
|
||||||
|
// Reactive data
|
||||||
|
const email = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const successMessage = ref('');
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const emailError = ref('');
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
switch (reason.value) {
|
||||||
|
case 'used':
|
||||||
|
return 'Verification Link Already Used';
|
||||||
|
case 'invalid':
|
||||||
|
return 'Invalid Verification Link';
|
||||||
|
default:
|
||||||
|
return 'Verification Link Expired';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageDescription = computed(() => {
|
||||||
|
switch (reason.value) {
|
||||||
|
case 'used':
|
||||||
|
return 'This verification link has already been used. If you need to verify your email again, please request a new verification link below.';
|
||||||
|
case 'invalid':
|
||||||
|
return 'The verification link you clicked is invalid or malformed. Please request a new verification link below.';
|
||||||
|
default:
|
||||||
|
return 'Your verification link has expired. Verification links are valid for 24 hours. Please request a new verification link below.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation rules
|
||||||
|
const emailRules = [
|
||||||
|
(v: string) => !!v || 'Email is required',
|
||||||
|
(v: string) => isValidEmail(v) || 'Please enter a valid email address'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper function
|
||||||
|
function isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resend verification email
|
||||||
|
async function resendVerification() {
|
||||||
|
if (!email.value || !isValidEmail(email.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
successMessage.value = '';
|
||||||
|
errorMessage.value = '';
|
||||||
|
emailError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/send-verification-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email: email.value }
|
||||||
|
});
|
||||||
|
|
||||||
|
successMessage.value = 'A new verification email has been sent! Please check your inbox and spam folder.';
|
||||||
|
email.value = ''; // Clear the form
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[verify-expired] Failed to resend verification:', error);
|
||||||
|
|
||||||
|
if (error.status === 404) {
|
||||||
|
emailError.value = 'No account found with this email address.';
|
||||||
|
} else if (error.status === 429) {
|
||||||
|
errorMessage.value = 'Please wait a few minutes before requesting another verification email.';
|
||||||
|
} else {
|
||||||
|
errorMessage.value = error.data?.message || 'Failed to send verification email. Please try again.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set page title
|
||||||
|
useHead({
|
||||||
|
title: `${pageTitle.value} - MonacoUSA Portal`,
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Request a new email verification link for your MonacoUSA Portal account.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[verify-expired] Page accessed', { reason: reason.value });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.verification-expired {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-height {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-t {
|
||||||
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for the warning icon */
|
||||||
|
.v-icon {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List styling */
|
||||||
|
ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<template>
|
||||||
|
<div class="verification-success">
|
||||||
|
<v-container class="fill-height" fluid>
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="6" lg="4">
|
||||||
|
<v-card class="elevation-12 rounded-lg">
|
||||||
|
<v-card-text class="text-center pa-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<v-icon
|
||||||
|
color="success"
|
||||||
|
size="80"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
mdi-check-circle
|
||||||
|
</v-icon>
|
||||||
|
|
||||||
|
<h1 class="text-h4 font-weight-bold text-success mb-3">
|
||||||
|
Email Verified Successfully!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-body-1 text-medium-emphasis mb-2" v-if="email">
|
||||||
|
Your email address <strong>{{ email }}</strong> has been verified.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-body-1 text-medium-emphasis mb-6">
|
||||||
|
Your MonacoUSA Portal account is now active and ready to use.
|
||||||
|
You can now log in to access your dashboard and member features.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Warning message for partial verification -->
|
||||||
|
<v-alert
|
||||||
|
v-if="partialWarning"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4 text-start"
|
||||||
|
icon="mdi-information"
|
||||||
|
>
|
||||||
|
<div class="text-body-2">
|
||||||
|
<strong>Note:</strong> Your email has been verified, but there may have been
|
||||||
|
a minor issue updating your account status. If you experience any login
|
||||||
|
problems, please contact support.
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
variant="elevated"
|
||||||
|
block
|
||||||
|
:to="{ path: '/login', query: { verified: 'true' } }"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-login</v-icon>
|
||||||
|
Log In to Portal
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
to="/"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-home</v-icon>
|
||||||
|
Return to Home
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional help -->
|
||||||
|
<div class="mt-6 pt-4 border-t">
|
||||||
|
<p class="text-caption text-medium-emphasis mb-2">
|
||||||
|
Need help? Contact support at:
|
||||||
|
</p>
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-icon="mdi-email"
|
||||||
|
>
|
||||||
|
support@monacousa.org
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
middleware: 'guest'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get query parameters
|
||||||
|
const route = useRoute();
|
||||||
|
const email = computed(() => route.query.email as string || '');
|
||||||
|
const partialWarning = computed(() => route.query.warning === 'partial');
|
||||||
|
|
||||||
|
// Set page title
|
||||||
|
useHead({
|
||||||
|
title: 'Email Verified - MonacoUSA Portal',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Your email has been successfully verified. You can now log in to the MonacoUSA Portal.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track successful verification
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[verify-success] Email verification completed', {
|
||||||
|
email: email.value,
|
||||||
|
partialWarning: partialWarning.value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.verification-success {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-height {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-t {
|
||||||
|
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for the success icon */
|
||||||
|
.v-icon {
|
||||||
|
animation: bounce 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 20%, 53%, 80%, 100% {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
40%, 43% {
|
||||||
|
transform: translate3d(0, -8px, 0);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate3d(0, -4px, 0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate3d(0, -2px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
pages/signup.vue
123
pages/signup.vue
|
|
@ -422,33 +422,59 @@ function loadRecaptchaScript(siteKey: string) {
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add page initialization state
|
||||||
|
const pageReady = ref(false);
|
||||||
|
|
||||||
// Load configurations on mount
|
// Load configurations on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
console.log('🚀 Initializing signup page...');
|
||||||
|
|
||||||
|
// Set a timeout to ensure page shows even if API calls fail
|
||||||
|
const initTimeout = setTimeout(() => {
|
||||||
|
if (!pageReady.value) {
|
||||||
|
console.warn('⚠️ API calls taking too long, showing page with defaults');
|
||||||
|
pageReady.value = true;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load reCAPTCHA config (public endpoint - no authentication required)
|
// Load reCAPTCHA config with timeout
|
||||||
const recaptchaResponse = await $fetch('/api/recaptcha-config') as any;
|
try {
|
||||||
if (recaptchaResponse?.success && recaptchaResponse?.data?.siteKey) {
|
const recaptchaResponse = await Promise.race([
|
||||||
recaptchaConfig.value.siteKey = recaptchaResponse.data.siteKey;
|
$fetch('/api/recaptcha-config'),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000))
|
||||||
|
]) as any;
|
||||||
|
|
||||||
// Load reCAPTCHA script dynamically
|
if (recaptchaResponse?.success && recaptchaResponse?.data?.siteKey) {
|
||||||
loadRecaptchaScript(recaptchaConfig.value.siteKey);
|
recaptchaConfig.value.siteKey = recaptchaResponse.data.siteKey;
|
||||||
|
loadRecaptchaScript(recaptchaConfig.value.siteKey);
|
||||||
console.log('✅ reCAPTCHA site key loaded successfully');
|
console.log('✅ reCAPTCHA site key loaded successfully');
|
||||||
} else {
|
}
|
||||||
console.warn('❌ reCAPTCHA not configured or failed to load');
|
} catch (error) {
|
||||||
|
console.warn('❌ reCAPTCHA config failed to load:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load registration config (public endpoint - no authentication required)
|
// Load registration config with timeout
|
||||||
const registrationResponse = await $fetch('/api/registration-config') as any;
|
try {
|
||||||
if (registrationResponse?.success) {
|
const registrationResponse = await Promise.race([
|
||||||
registrationConfig.value = registrationResponse.data;
|
$fetch('/api/registration-config'),
|
||||||
console.log('✅ Registration config loaded successfully');
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000))
|
||||||
} else {
|
]) as any;
|
||||||
console.warn('❌ Registration config failed to load');
|
|
||||||
|
if (registrationResponse?.success) {
|
||||||
|
registrationConfig.value = registrationResponse.data;
|
||||||
|
console.log('✅ Registration config loaded successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('❌ Registration config failed to load:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load configuration:', error);
|
console.error('Failed to load configuration:', error);
|
||||||
// Page will still work with default values
|
} finally {
|
||||||
|
clearTimeout(initTimeout);
|
||||||
|
pageReady.value = true;
|
||||||
|
console.log('✅ Signup page ready');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -456,56 +482,33 @@ onMounted(async () => {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.signup-container {
|
.signup-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* Dynamic viewport height for mobile */
|
||||||
background: linear-gradient(rgba(163, 21, 21, 0.7), rgba(0, 0, 0, 0.5)),
|
background: linear-gradient(rgba(163, 21, 21, 0.7), rgba(0, 0, 0, 0.5)),
|
||||||
url('/monaco_high_res.jpg');
|
url('/monaco_high_res.jpg');
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-attachment: fixed;
|
background-repeat: no-repeat;
|
||||||
display: flex;
|
background-attachment: scroll;
|
||||||
align-items: center;
|
padding: 20px 0;
|
||||||
justify-content: center;
|
position: relative;
|
||||||
/* Prevent overscroll bounce on mobile Safari */
|
}
|
||||||
overscroll-behavior: none;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
/* Ensure background covers full content */
|
||||||
position: fixed;
|
.signup-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
overflow-y: auto;
|
background: linear-gradient(rgba(163, 21, 21, 0.7), rgba(0, 0, 0, 0.5)),
|
||||||
}
|
url('/monaco_high_res.jpg');
|
||||||
|
background-size: cover;
|
||||||
/* Fix for mobile Safari overscroll */
|
background-position: center;
|
||||||
html, body {
|
background-repeat: no-repeat;
|
||||||
overscroll-behavior: none;
|
background-attachment: scroll;
|
||||||
-webkit-overflow-scrolling: touch;
|
z-index: -1;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Safari-specific fixes */
|
|
||||||
@supports (-webkit-touch-callout: none) {
|
|
||||||
.signup-container {
|
|
||||||
background-attachment: scroll;
|
|
||||||
position: fixed;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure background extends beyond viewport */
|
|
||||||
.signup-container::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -100px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: -100px;
|
|
||||||
background: linear-gradient(rgba(163, 21, 21, 0.7), rgba(0, 0, 0, 0.5)),
|
|
||||||
url('/monaco_high_res.jpg');
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.signup-card {
|
.signup-card {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log('[api/admin/smtp-config.get] =========================');
|
||||||
|
console.log('[api/admin/smtp-config.get] GET /api/admin/smtp-config - Get SMTP configuration');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate session and require admin privileges
|
||||||
|
const sessionManager = createSessionManager();
|
||||||
|
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||||
|
const session = sessionManager.getSession(cookieHeader);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Authentication required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.tier !== 'admin') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Admin privileges required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[api/admin/smtp-config.get] Authorized admin:', session.user.email);
|
||||||
|
|
||||||
|
// Get SMTP configuration
|
||||||
|
const { getSMTPConfig } = await import('~/server/utils/admin-config');
|
||||||
|
const config = getSMTPConfig();
|
||||||
|
|
||||||
|
// Hide password for security
|
||||||
|
const safeConfig = {
|
||||||
|
...config,
|
||||||
|
password: config.password ? '••••••••••••••••' : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: safeConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[api/admin/smtp-config.get] ❌ Error getting SMTP config:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log('[api/admin/smtp-config.post] =========================');
|
||||||
|
console.log('[api/admin/smtp-config.post] POST /api/admin/smtp-config - Save SMTP configuration');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate session and require admin privileges
|
||||||
|
const sessionManager = createSessionManager();
|
||||||
|
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||||
|
const session = sessionManager.getSession(cookieHeader);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Authentication required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.tier !== 'admin') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Admin privileges required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[api/admin/smtp-config.post] Authorized admin:', session.user.email);
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await readBody(event);
|
||||||
|
console.log('[api/admin/smtp-config.post] Request body:', {
|
||||||
|
...body,
|
||||||
|
password: body.password ? '••••••••••••••••' : ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.host || !body.port || !body.fromAddress || !body.fromName) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Missing required SMTP configuration fields'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(body.fromAddress)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid from address email format'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate port is a number
|
||||||
|
const port = parseInt(body.port, 10);
|
||||||
|
if (isNaN(port) || port < 1 || port > 65535) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Port must be a valid number between 1 and 65535'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare configuration object
|
||||||
|
const smtpConfig = {
|
||||||
|
host: body.host.trim(),
|
||||||
|
port: port,
|
||||||
|
secure: Boolean(body.secure),
|
||||||
|
username: body.username?.trim() || '',
|
||||||
|
password: body.password?.trim() || '',
|
||||||
|
fromAddress: body.fromAddress.trim(),
|
||||||
|
fromName: body.fromName.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[api/admin/smtp-config.post] Saving SMTP config:', {
|
||||||
|
...smtpConfig,
|
||||||
|
password: smtpConfig.password ? '••••••••••••••••' : ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save SMTP configuration
|
||||||
|
const { saveSMTPConfig } = await import('~/server/utils/admin-config');
|
||||||
|
await saveSMTPConfig(smtpConfig, session.user.email);
|
||||||
|
|
||||||
|
console.log('[api/admin/smtp-config.post] ✅ SMTP configuration saved successfully');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'SMTP configuration saved successfully'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[api/admin/smtp-config.post] ❌ Error saving SMTP config:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log('[api/admin/test-email.post] =========================');
|
||||||
|
console.log('[api/admin/test-email.post] POST /api/admin/test-email - Send test email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate session and require admin privileges
|
||||||
|
const sessionManager = createSessionManager();
|
||||||
|
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||||
|
const session = sessionManager.getSession(cookieHeader);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Authentication required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.tier !== 'admin') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Admin privileges required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[api/admin/test-email.post] Authorized admin:', session.user.email);
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await readBody(event);
|
||||||
|
console.log('[api/admin/test-email.post] Request body:', body);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.testEmail) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Test email address is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(body.testEmail)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid email address format'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[api/admin/test-email.post] Sending test email to:', body.testEmail);
|
||||||
|
|
||||||
|
// Get email service and send test email
|
||||||
|
const { getEmailService } = await import('~/server/utils/email');
|
||||||
|
const emailService = getEmailService();
|
||||||
|
|
||||||
|
// Verify connection first
|
||||||
|
const connectionOk = await emailService.verifyConnection();
|
||||||
|
if (!connectionOk) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'SMTP connection verification failed. Please check your SMTP configuration.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
await emailService.sendTestEmail(body.testEmail);
|
||||||
|
|
||||||
|
console.log('[api/admin/test-email.post] ✅ Test email sent successfully');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${body.testEmail}`
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[api/admin/test-email.post] ❌ Error sending test email:', error);
|
||||||
|
|
||||||
|
// Provide more specific error messages for common SMTP issues
|
||||||
|
let errorMessage = error.message || 'Failed to send test email';
|
||||||
|
|
||||||
|
if (error.code === 'EAUTH') {
|
||||||
|
errorMessage = 'SMTP authentication failed. Please check your username and password.';
|
||||||
|
} else if (error.code === 'ECONNECTION' || error.code === 'ETIMEDOUT') {
|
||||||
|
errorMessage = 'Could not connect to SMTP server. Please check your host and port settings.';
|
||||||
|
} else if (error.code === 'ESOCKET') {
|
||||||
|
errorMessage = 'Socket error. Please check your network connection and SMTP settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { email } = body;
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Email is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid email format'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[send-verification-email] Processing request for email:', email);
|
||||||
|
|
||||||
|
// Check if user exists in Keycloak
|
||||||
|
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||||
|
const keycloak = createKeycloakAdminClient();
|
||||||
|
|
||||||
|
let existingUsers;
|
||||||
|
try {
|
||||||
|
existingUsers = await keycloak.findUserByEmail(email.toLowerCase().trim());
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[send-verification-email] Failed to search users:', error.message);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to verify account status'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingUsers || existingUsers.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'No account found with this email address'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = existingUsers[0];
|
||||||
|
|
||||||
|
// Check if user is already verified
|
||||||
|
if (user.emailVerified) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'This email address is already verified'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting: check if we recently sent an email to this address
|
||||||
|
const rateLimitKey = `verification_email_${email.toLowerCase()}`;
|
||||||
|
|
||||||
|
// Simple in-memory rate limiting (in production, use Redis)
|
||||||
|
const globalCache = globalThis as any;
|
||||||
|
if (!globalCache.verificationEmailCache) {
|
||||||
|
globalCache.verificationEmailCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSent = globalCache.verificationEmailCache.get(rateLimitKey);
|
||||||
|
const cooldownPeriod = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
|
if (lastSent && Date.now() - lastSent < cooldownPeriod) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Please wait a few minutes before requesting another verification email'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||||
|
const verificationToken = await generateEmailVerificationToken(user.id, email);
|
||||||
|
|
||||||
|
// Get configuration
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
const { getEmailService } = await import('~/server/utils/email');
|
||||||
|
const emailService = getEmailService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emailService.sendWelcomeEmail(email, {
|
||||||
|
firstName: user.firstName || '',
|
||||||
|
lastName: user.lastName || '',
|
||||||
|
verificationLink,
|
||||||
|
memberId: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[send-verification-email] Successfully sent verification email to:', email);
|
||||||
|
|
||||||
|
// Update rate limiting cache
|
||||||
|
globalCache.verificationEmailCache.set(rateLimitKey, Date.now());
|
||||||
|
|
||||||
|
// Clean up old rate limit entries periodically
|
||||||
|
if (Math.random() < 0.1) { // 10% chance
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, timestamp] of globalCache.verificationEmailCache.entries()) {
|
||||||
|
if (now - timestamp > cooldownPeriod * 2) {
|
||||||
|
globalCache.verificationEmailCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Verification email sent successfully'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (emailError: any) {
|
||||||
|
console.error('[send-verification-email] Failed to send email:', emailError.message);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to send verification email. Please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[send-verification-email] Request failed:', error.message);
|
||||||
|
|
||||||
|
// Re-throw HTTP errors
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unexpected errors
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'An unexpected error occurred. Please try again.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const { token } = getQuery(event);
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Verification token is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[verify-email] Processing verification token...');
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
const { verifyEmailToken } = await import('~/server/utils/email-tokens');
|
||||||
|
const { userId, email } = await verifyEmailToken(token);
|
||||||
|
|
||||||
|
// Update user verification status in Keycloak
|
||||||
|
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||||
|
const keycloak = createKeycloakAdminClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await keycloak.updateUserProfile(userId, {
|
||||||
|
emailVerified: true,
|
||||||
|
attributes: {
|
||||||
|
lastLoginDate: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[verify-email] Successfully verified user:', userId, 'email:', email);
|
||||||
|
|
||||||
|
// Redirect to success page
|
||||||
|
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email), 302);
|
||||||
|
|
||||||
|
} catch (keycloakError: any) {
|
||||||
|
console.error('[verify-email] Keycloak update failed:', keycloakError.message);
|
||||||
|
|
||||||
|
// Even if Keycloak update fails, consider verification successful if token was valid
|
||||||
|
// This prevents user frustration due to backend issues
|
||||||
|
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email) + '&warning=partial', 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[verify-email] Verification failed:', error.message);
|
||||||
|
|
||||||
|
// Handle different error types with appropriate redirects
|
||||||
|
if (error.message?.includes('expired')) {
|
||||||
|
return sendRedirect(event, '/auth/verify-expired', 302);
|
||||||
|
} else if (error.message?.includes('already used') || error.message?.includes('not found')) {
|
||||||
|
return sendRedirect(event, '/auth/verify-expired?reason=used', 302);
|
||||||
|
} else {
|
||||||
|
// For other errors, show a generic error page
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: error.message || 'Invalid verification link'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -111,11 +111,35 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
console.log('[api/members/[id]/create-portal-account.post] Created Keycloak user with ID:', keycloakId);
|
console.log('[api/members/[id]/create-portal-account.post] Created Keycloak user with ID:', keycloakId);
|
||||||
|
|
||||||
// 6. Update member record with keycloak_id
|
// 8. Update member record with keycloak_id
|
||||||
console.log('[api/members/[id]/create-portal-account.post] Updating member record with keycloak_id...');
|
console.log('[api/members/[id]/create-portal-account.post] Updating member record with keycloak_id...');
|
||||||
const { updateMember } = await import('~/server/utils/nocodb');
|
const { updateMember } = await import('~/server/utils/nocodb');
|
||||||
await updateMember(memberId, { keycloak_id: keycloakId });
|
await updateMember(memberId, { keycloak_id: keycloakId });
|
||||||
|
|
||||||
|
// 9. Send welcome/verification email using our custom email system
|
||||||
|
console.log('[api/members/[id]/create-portal-account.post] Sending welcome/verification email...');
|
||||||
|
try {
|
||||||
|
const { getEmailService } = await import('~/server/utils/email');
|
||||||
|
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||||
|
|
||||||
|
const emailService = getEmailService();
|
||||||
|
const verificationToken = await generateEmailVerificationToken(keycloakId, member.email);
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
await emailService.sendWelcomeEmail(member.email, {
|
||||||
|
firstName: member.first_name,
|
||||||
|
lastName: member.last_name,
|
||||||
|
verificationLink,
|
||||||
|
memberId: memberId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[api/members/[id]/create-portal-account.post] Welcome email sent successfully');
|
||||||
|
} catch (emailError: any) {
|
||||||
|
console.error('[api/members/[id]/create-portal-account.post] Failed to send welcome email:', emailError.message);
|
||||||
|
// Don't fail the account creation if email fails - user can resend verification email later
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[api/members/[id]/create-portal-account.post] ✅ Portal account creation successful');
|
console.log('[api/members/[id]/create-portal-account.post] ✅ Portal account creation successful');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,30 @@ export default defineEventHandler(async (event) => {
|
||||||
const member = await nocodb.create('members', memberData);
|
const member = await nocodb.create('members', memberData);
|
||||||
createdMemberId = member.Id;
|
createdMemberId = member.Id;
|
||||||
|
|
||||||
|
// 7. Send welcome/verification email using our custom email system
|
||||||
|
console.log('[api/registration.post] Sending welcome/verification email...');
|
||||||
|
try {
|
||||||
|
const { getEmailService } = await import('~/server/utils/email');
|
||||||
|
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||||
|
|
||||||
|
const emailService = getEmailService();
|
||||||
|
const verificationToken = await generateEmailVerificationToken(createdKeycloakId, body.email);
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
await emailService.sendWelcomeEmail(body.email, {
|
||||||
|
firstName: body.first_name,
|
||||||
|
lastName: body.last_name,
|
||||||
|
verificationLink,
|
||||||
|
memberId: createdMemberId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[api/registration.post] Welcome email sent successfully');
|
||||||
|
} catch (emailError: any) {
|
||||||
|
console.error('[api/registration.post] Failed to send welcome email:', emailError.message);
|
||||||
|
// Don't fail the registration if email fails - user can resend verification email later
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`);
|
console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SMTP Test Email</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #a31515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #a31515;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-badge {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 25px;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-details {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #a31515;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-details h3 {
|
||||||
|
color: #a31515;
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 120px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: #333;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #a31515;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
min-width: auto;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
|
||||||
|
<h1 class="title">SMTP Test Email</h1>
|
||||||
|
<p class="subtitle">Email Configuration Test</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div class="success-badge">
|
||||||
|
✅ SMTP Configuration Working!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Congratulations! This test email confirms that your SMTP email configuration is working correctly. The MonacoUSA Portal email system is now ready to send emails.</p>
|
||||||
|
|
||||||
|
<div class="test-details">
|
||||||
|
<h3>📊 Test Configuration Details</h3>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">Test Time:</div>
|
||||||
|
<div class="detail-value">{{testTime}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">SMTP Host:</div>
|
||||||
|
<div class="detail-value">{{smtpHost}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">From Address:</div>
|
||||||
|
<div class="detail-value">{{fromAddress}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">Email Type:</div>
|
||||||
|
<div class="detail-value">SMTP Configuration Test</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>🎯 What This Means</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>✅ Connection Established:</strong> Successfully connected to your SMTP server</li>
|
||||||
|
<li><strong>✅ Authentication Passed:</strong> SMTP credentials are correct and working</li>
|
||||||
|
<li><strong>✅ Email Delivery:</strong> Emails can be sent from the portal</li>
|
||||||
|
<li><strong>✅ Template System:</strong> Email templates are loading and rendering correctly</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>📧 Available Email Types</h3>
|
||||||
|
<p>Your portal can now send the following types of emails:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Welcome Emails:</strong> New member registration confirmations</li>
|
||||||
|
<li><strong>Email Verification:</strong> Account activation links</li>
|
||||||
|
<li><strong>Password Reset:</strong> Secure password reset instructions</li>
|
||||||
|
<li><strong>Dues Reminders:</strong> Membership payment notifications</li>
|
||||||
|
<li><strong>General Notifications:</strong> Administrative communications</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style="background: rgba(40, 167, 69, 0.1); padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;">
|
||||||
|
<p style="margin: 0; color: #155724;">
|
||||||
|
<strong>✨ Success!</strong> Your email system is fully configured and operational.
|
||||||
|
All automated emails from the MonacoUSA Portal will now be delivered successfully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>MonacoUSA Portal</strong><br>
|
||||||
|
Email System Configuration Test</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://portal.monacousa.org" class="link">Portal</a> |
|
||||||
|
<a href="mailto:admin@monacousa.org" class="link">Admin Support</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #999;">
|
||||||
|
This is an automated test email to verify SMTP configuration.<br>
|
||||||
|
Generated by MonacoUSA Portal Email System
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Welcome to MonacoUSA</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #a31515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #a31515;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #a31515;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #a31515;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info h3 {
|
||||||
|
color: #a31515;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 15px 30px;
|
||||||
|
background: linear-gradient(135deg, #a31515 0%, #c41e1e 100%);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-button:hover {
|
||||||
|
background: linear-gradient(135deg, #8b1212 0%, #a31515 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(163, 21, 21, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-info {
|
||||||
|
background: rgba(163, 21, 21, 0.05);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(163, 21, 21, 0.1);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-info h3 {
|
||||||
|
color: #a31515;
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #a31515;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-button {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
|
||||||
|
<h1 class="title">Welcome to MonacoUSA</h1>
|
||||||
|
<p class="subtitle">Monaco - United States Association</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="greeting">
|
||||||
|
Dear {{firstName}} {{lastName}},
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Thank you for registering to become a member of the <strong>MonacoUSA Association</strong>! We're excited to welcome you to our community that bridges Monaco and the United States.</p>
|
||||||
|
|
||||||
|
<div class="member-info">
|
||||||
|
<h3>🎉 Registration Successful</h3>
|
||||||
|
<p><strong>Member ID:</strong> {{memberId}}</p>
|
||||||
|
<p><strong>Registration Date:</strong> {{registrationDate}}</p>
|
||||||
|
<p>Your membership application has been received and is being processed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>📧 Next Step: Verify Your Email</h3>
|
||||||
|
<p>To complete your registration and activate your account, please verify your email address by clicking the button below:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{verificationLink}}" class="verify-button">
|
||||||
|
✉️ Verify Email Address
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><em>This verification link will expire in 24 hours for security purposes.</em></p>
|
||||||
|
|
||||||
|
<div class="payment-info">
|
||||||
|
<h3>💳 Membership Dues Payment</h3>
|
||||||
|
<p>Once your email is verified, you'll be able to log in to the portal. To activate your membership, please transfer your annual membership dues:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Amount:</strong> €50/year</li>
|
||||||
|
<li><strong>Payment method:</strong> Bank transfer (details in your portal)</li>
|
||||||
|
<li><strong>Status:</strong> Your account will be activated once payment is verified</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><em>You can find complete payment instructions in your member portal after verification.</em></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>🌟 What's Next?</h3>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Verify your email</strong> using the button above</li>
|
||||||
|
<li><strong>Log in to your portal</strong> at <a href="https://portal.monacousa.org" class="link">portal.monacousa.org</a></li>
|
||||||
|
<li><strong>Complete your payment</strong> to activate your membership</li>
|
||||||
|
<li><strong>Enjoy member benefits</strong> and connect with our community</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>If you have any questions, please don't hesitate to contact us. We're here to help!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>MonacoUSA Association</strong><br>
|
||||||
|
Connecting Monaco and the United States</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://portal.monacousa.org" class="link">Portal</a> |
|
||||||
|
<a href="mailto:info@monacousa.org" class="link">Contact Us</a> |
|
||||||
|
<a href="https://monacousa.org" class="link">Website</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #999;">
|
||||||
|
This email was sent to {{email}} regarding your MonacoUSA membership registration.<br>
|
||||||
|
If you did not register for this account, please ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, access, constants } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
|
||||||
import type { NocoDBSettings } from '~/utils/types';
|
import type { NocoDBSettings, SMTPConfig } from '~/utils/types';
|
||||||
|
|
||||||
interface AdminConfiguration {
|
interface AdminConfiguration {
|
||||||
nocodb: NocoDBSettings;
|
nocodb: NocoDBSettings;
|
||||||
|
|
@ -15,6 +15,15 @@ interface AdminConfiguration {
|
||||||
iban: string;
|
iban: string;
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
};
|
};
|
||||||
|
smtp?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string; // Will be encrypted
|
||||||
|
fromAddress: string;
|
||||||
|
fromName: string;
|
||||||
|
};
|
||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +198,9 @@ export async function loadAdminConfig(): Promise<AdminConfiguration | null> {
|
||||||
if (config.recaptcha?.secretKey) {
|
if (config.recaptcha?.secretKey) {
|
||||||
config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey);
|
config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey);
|
||||||
}
|
}
|
||||||
|
if (config.smtp?.password) {
|
||||||
|
config.smtp.password = decryptSensitiveData(config.smtp.password);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[admin-config] Configuration loaded from file');
|
console.log('[admin-config] Configuration loaded from file');
|
||||||
configCache = config;
|
configCache = config;
|
||||||
|
|
@ -409,6 +421,65 @@ export function getRegistrationConfig(): { membershipFee: number; iban: string;
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save SMTP configuration
|
||||||
|
*/
|
||||||
|
export async function saveSMTPConfig(config: SMTPConfig, updatedBy: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
await createBackup();
|
||||||
|
|
||||||
|
const currentConfig = configCache || await loadAdminConfig() || {
|
||||||
|
nocodb: { url: '', apiKey: '', baseId: '', tables: {} },
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
updatedBy: 'system'
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedConfig: AdminConfiguration = {
|
||||||
|
...currentConfig,
|
||||||
|
smtp: {
|
||||||
|
...config,
|
||||||
|
password: encryptSensitiveData(config.password)
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
updatedBy
|
||||||
|
};
|
||||||
|
|
||||||
|
const configJson = JSON.stringify(updatedConfig, null, 2);
|
||||||
|
await writeFile(CONFIG_FILE, configJson, 'utf-8');
|
||||||
|
|
||||||
|
// Update cache with unencrypted data
|
||||||
|
configCache = {
|
||||||
|
...updatedConfig,
|
||||||
|
smtp: { ...config } // Keep original unencrypted data in cache
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[admin-config] SMTP configuration saved');
|
||||||
|
await logConfigChange('SMTP_CONFIG_SAVED', updatedBy, { host: config.host, fromAddress: config.fromAddress });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[admin-config] Failed to save SMTP configuration:', error);
|
||||||
|
await logConfigChange('SMTP_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SMTP configuration
|
||||||
|
*/
|
||||||
|
export function getSMTPConfig(): SMTPConfig {
|
||||||
|
const config = configCache?.smtp || {
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
fromAddress: '',
|
||||||
|
fromName: 'MonacoUSA Portal'
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize configuration system on server startup
|
* Initialize configuration system on server startup
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { sign, verify } from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface EmailVerificationTokenPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
purpose: 'email-verification';
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory token storage for validation (in production, consider Redis)
|
||||||
|
const activeTokens = new Map<string, EmailVerificationTokenPayload>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure JWT token for email verification
|
||||||
|
*/
|
||||||
|
export async function generateEmailVerificationToken(userId: string, email: string): Promise<string> {
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
|
||||||
|
if (!runtimeConfig.jwtSecret) {
|
||||||
|
throw new Error('JWT secret not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: EmailVerificationTokenPayload = {
|
||||||
|
userId,
|
||||||
|
email: email.toLowerCase().trim(),
|
||||||
|
purpose: 'email-verification',
|
||||||
|
iat: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = sign(payload, runtimeConfig.jwtSecret, {
|
||||||
|
expiresIn: '24h',
|
||||||
|
issuer: 'monacousa-portal',
|
||||||
|
audience: 'email-verification'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store token metadata for additional validation
|
||||||
|
activeTokens.set(token, payload);
|
||||||
|
|
||||||
|
// Clean up expired tokens periodically
|
||||||
|
setTimeout(() => {
|
||||||
|
activeTokens.delete(token);
|
||||||
|
}, 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
|
console.log('[email-tokens] Generated verification token for user:', userId, 'email:', email);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and decode an email verification token
|
||||||
|
*/
|
||||||
|
export async function verifyEmailToken(token: string): Promise<{ userId: string; email: string }> {
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
|
||||||
|
if (!runtimeConfig.jwtSecret) {
|
||||||
|
throw new Error('JWT secret not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Token is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify JWT signature and expiration
|
||||||
|
const decoded = verify(token, runtimeConfig.jwtSecret, {
|
||||||
|
issuer: 'monacousa-portal',
|
||||||
|
audience: 'email-verification'
|
||||||
|
}) as EmailVerificationTokenPayload;
|
||||||
|
|
||||||
|
// Validate token purpose
|
||||||
|
if (decoded.purpose !== 'email-verification') {
|
||||||
|
throw new Error('Invalid token purpose');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token exists in our active tokens (prevents replay attacks)
|
||||||
|
const storedPayload = activeTokens.get(token);
|
||||||
|
if (!storedPayload) {
|
||||||
|
throw new Error('Token not found or already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate payload consistency
|
||||||
|
if (storedPayload.userId !== decoded.userId || storedPayload.email !== decoded.email) {
|
||||||
|
throw new Error('Token payload mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove token after successful verification (single use)
|
||||||
|
activeTokens.delete(token);
|
||||||
|
|
||||||
|
console.log('[email-tokens] Successfully verified token for user:', decoded.userId, 'email:', decoded.email);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: decoded.userId,
|
||||||
|
email: decoded.email
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[email-tokens] Token verification failed:', error.message);
|
||||||
|
|
||||||
|
// Provide user-friendly error messages
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
throw new Error('Verification link has expired. Please request a new one.');
|
||||||
|
} else if (error.name === 'JsonWebTokenError') {
|
||||||
|
throw new Error('Invalid verification link.');
|
||||||
|
} else {
|
||||||
|
throw new Error(error.message || 'Token verification failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a token is still valid without consuming it
|
||||||
|
*/
|
||||||
|
export async function isTokenValid(token: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
|
||||||
|
if (!runtimeConfig.jwtSecret || !token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verify(token, runtimeConfig.jwtSecret, {
|
||||||
|
issuer: 'monacousa-portal',
|
||||||
|
audience: 'email-verification'
|
||||||
|
}) as EmailVerificationTokenPayload;
|
||||||
|
|
||||||
|
return decoded.purpose === 'email-verification' && activeTokens.has(token);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired tokens from memory
|
||||||
|
*/
|
||||||
|
export function cleanupExpiredTokens(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const expirationTime = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
for (const [token, payload] of activeTokens.entries()) {
|
||||||
|
if (now - payload.iat > expirationTime) {
|
||||||
|
activeTokens.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[email-tokens] Cleaned up expired tokens. Active tokens:', activeTokens.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about active tokens
|
||||||
|
*/
|
||||||
|
export function getTokenStats(): { activeTokens: number; oldestToken: number | null } {
|
||||||
|
const now = Date.now();
|
||||||
|
let oldestToken: number | null = null;
|
||||||
|
|
||||||
|
for (const payload of activeTokens.values()) {
|
||||||
|
if (oldestToken === null || payload.iat < oldestToken) {
|
||||||
|
oldestToken = payload.iat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTokens: activeTokens.size,
|
||||||
|
oldestToken: oldestToken ? Math.floor((now - oldestToken) / 1000 / 60) : null // minutes ago
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of expired tokens (every hour)
|
||||||
|
setInterval(cleanupExpiredTokens, 60 * 60 * 1000);
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import handlebars from 'handlebars';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { SMTPConfig } from '~/utils/types';
|
||||||
|
|
||||||
|
export interface EmailData {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelcomeEmailData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
verificationLink: string;
|
||||||
|
memberId: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationEmailData {
|
||||||
|
firstName: string;
|
||||||
|
verificationLink: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetEmailData {
|
||||||
|
firstName: string;
|
||||||
|
resetLink: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuesReminderEmailData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
amount: number;
|
||||||
|
dueDate: string;
|
||||||
|
iban?: string;
|
||||||
|
accountHolder?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailService {
|
||||||
|
private transporter: nodemailer.Transporter | null = null;
|
||||||
|
private templates: Map<string, handlebars.TemplateDelegate> = new Map();
|
||||||
|
|
||||||
|
constructor(private config: SMTPConfig) {
|
||||||
|
this.initializeTransporter();
|
||||||
|
this.preloadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the nodemailer transporter
|
||||||
|
*/
|
||||||
|
private initializeTransporter(): void {
|
||||||
|
if (!this.config.host || !this.config.port) {
|
||||||
|
console.warn('[EmailService] SMTP configuration incomplete, emails will not be sent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.transporter = nodemailer.createTransport({
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
secure: this.config.secure, // true for 465, false for other ports
|
||||||
|
auth: this.config.username && this.config.password ? {
|
||||||
|
user: this.config.username,
|
||||||
|
pass: this.config.password
|
||||||
|
} : undefined,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false // Accept self-signed certificates in development
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[EmailService] ✅ SMTP transporter initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmailService] ❌ Failed to initialize SMTP transporter:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload and compile email templates
|
||||||
|
*/
|
||||||
|
private preloadTemplates(): void {
|
||||||
|
const templateNames = ['welcome', 'verification', 'password-reset', 'dues-reminder', 'test'];
|
||||||
|
|
||||||
|
templateNames.forEach(templateName => {
|
||||||
|
try {
|
||||||
|
const templatePath = join(process.cwd(), 'server/templates', `${templateName}.hbs`);
|
||||||
|
const templateContent = readFileSync(templatePath, 'utf-8');
|
||||||
|
const compiledTemplate = handlebars.compile(templateContent);
|
||||||
|
this.templates.set(templateName, compiledTemplate);
|
||||||
|
console.log(`[EmailService] ✅ Template '${templateName}' loaded`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[EmailService] ⚠️ Template '${templateName}' not found or failed to load:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a compiled template by name
|
||||||
|
*/
|
||||||
|
private getTemplate(templateName: string): handlebars.TemplateDelegate | null {
|
||||||
|
return this.templates.get(templateName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a generic email
|
||||||
|
*/
|
||||||
|
private async sendEmail(emailData: EmailData): Promise<void> {
|
||||||
|
if (!this.transporter) {
|
||||||
|
throw new Error('SMTP transporter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `${this.config.fromName} <${this.config.fromAddress}>`,
|
||||||
|
to: emailData.to,
|
||||||
|
subject: emailData.subject,
|
||||||
|
html: emailData.html,
|
||||||
|
text: emailData.text || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await this.transporter.sendMail(mailOptions);
|
||||||
|
console.log(`[EmailService] ✅ Email sent successfully to ${emailData.to}:`, info.messageId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[EmailService] ❌ Failed to send email to ${emailData.to}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send welcome/verification email to new members
|
||||||
|
*/
|
||||||
|
async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise<void> {
|
||||||
|
const template = this.getTemplate('welcome');
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Welcome email template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
...data,
|
||||||
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = template(templateData);
|
||||||
|
|
||||||
|
await this.sendEmail({
|
||||||
|
to,
|
||||||
|
subject: 'Welcome to MonacoUSA - Please Verify Your Email',
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EmailService] ✅ Welcome email sent to ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email verification email
|
||||||
|
*/
|
||||||
|
async sendVerificationEmail(to: string, data: VerificationEmailData): Promise<void> {
|
||||||
|
const template = this.getTemplate('verification');
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Verification email template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
...data,
|
||||||
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = template(templateData);
|
||||||
|
|
||||||
|
await this.sendEmail({
|
||||||
|
to,
|
||||||
|
subject: 'Verify Your Email - MonacoUSA Portal',
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EmailService] ✅ Verification email sent to ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email
|
||||||
|
*/
|
||||||
|
async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise<void> {
|
||||||
|
const template = this.getTemplate('password-reset');
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Password reset email template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
...data,
|
||||||
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = template(templateData);
|
||||||
|
|
||||||
|
await this.sendEmail({
|
||||||
|
to,
|
||||||
|
subject: 'Reset Your Password - MonacoUSA Portal',
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EmailService] ✅ Password reset email sent to ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send membership dues reminder email
|
||||||
|
*/
|
||||||
|
async sendDuesReminderEmail(to: string, data: DuesReminderEmailData): Promise<void> {
|
||||||
|
const template = this.getTemplate('dues-reminder');
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Dues reminder email template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
...data,
|
||||||
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = template(templateData);
|
||||||
|
|
||||||
|
await this.sendEmail({
|
||||||
|
to,
|
||||||
|
subject: `MonacoUSA Membership Dues Reminder - €${data.amount} Due`,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EmailService] ✅ Dues reminder email sent to ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email to verify SMTP configuration
|
||||||
|
*/
|
||||||
|
async sendTestEmail(to: string): Promise<void> {
|
||||||
|
const template = this.getTemplate('test');
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
testTime: new Date().toISOString(),
|
||||||
|
logoUrl: `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`,
|
||||||
|
smtpHost: this.config.host,
|
||||||
|
fromAddress: this.config.fromAddress
|
||||||
|
};
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
if (template) {
|
||||||
|
html = template(templateData);
|
||||||
|
} else {
|
||||||
|
// Fallback HTML if template is not available
|
||||||
|
html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>SMTP Test Email</title></head>
|
||||||
|
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<img src="${templateData.logoUrl}" alt="MonacoUSA" style="width: 100px;">
|
||||||
|
<h1 style="color: #a31515;">SMTP Test Email</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This is a test email from the MonacoUSA Portal email system.</p>
|
||||||
|
|
||||||
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Test Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Sent at: ${templateData.testTime}</li>
|
||||||
|
<li>SMTP Host: ${templateData.smtpHost}</li>
|
||||||
|
<li>From Address: ${templateData.fromAddress}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly!</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;">
|
||||||
|
<p style="color: #666; font-size: 14px; text-align: center;">
|
||||||
|
MonacoUSA Portal Email System
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendEmail({
|
||||||
|
to,
|
||||||
|
subject: 'SMTP Configuration Test - MonacoUSA Portal',
|
||||||
|
html,
|
||||||
|
text: `This is a test email from MonacoUSA Portal. Sent at: ${templateData.testTime}`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EmailService] ✅ Test email sent to ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify SMTP connection
|
||||||
|
*/
|
||||||
|
async verifyConnection(): Promise<boolean> {
|
||||||
|
if (!this.transporter) {
|
||||||
|
throw new Error('SMTP transporter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.transporter.verify();
|
||||||
|
console.log('[EmailService] ✅ SMTP connection verified');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmailService] ❌ SMTP connection verification failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SMTP configuration and reinitialize transporter
|
||||||
|
*/
|
||||||
|
updateConfig(newConfig: SMTPConfig): void {
|
||||||
|
this.config = newConfig;
|
||||||
|
this.initializeTransporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the transporter connection
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
if (this.transporter) {
|
||||||
|
this.transporter.close();
|
||||||
|
this.transporter = null;
|
||||||
|
console.log('[EmailService] SMTP connection closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance for reuse across the application
|
||||||
|
let emailServiceInstance: EmailService | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create EmailService instance with current SMTP config
|
||||||
|
*/
|
||||||
|
export function getEmailService(): EmailService {
|
||||||
|
const { getSMTPConfig } = require('./admin-config');
|
||||||
|
const config = getSMTPConfig();
|
||||||
|
|
||||||
|
if (!emailServiceInstance) {
|
||||||
|
emailServiceInstance = new EmailService(config);
|
||||||
|
} else {
|
||||||
|
// Update config in case it changed
|
||||||
|
emailServiceInstance.updateConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return emailServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new EmailService instance with custom config
|
||||||
|
*/
|
||||||
|
export function createEmailService(config: SMTPConfig): EmailService {
|
||||||
|
return new EmailService(config);
|
||||||
|
}
|
||||||
|
|
@ -189,6 +189,16 @@ export interface RegistrationConfig {
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SMTPConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
fromAddress: string;
|
||||||
|
fromName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Enhanced Keycloak Admin API Types
|
// Enhanced Keycloak Admin API Types
|
||||||
export interface KeycloakUserRepresentation {
|
export interface KeycloakUserRepresentation {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue