Add email verification system for user registration
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s

- 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:
2025-08-08 22:51:14 +02:00
parent 7b72d7a565
commit 4ec05e29dc
20 changed files with 2501 additions and 227 deletions

View File

@@ -35,6 +35,10 @@
<v-icon start>mdi-account-plus</v-icon>
Registration
</v-tab>
<v-tab value="email">
<v-icon start>mdi-email</v-icon>
Email
</v-tab>
</v-tabs>
<v-card-text class="pa-6" style="min-height: 400px;">
@@ -281,6 +285,177 @@
</v-row>
</v-form>
</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>
<!-- Success Message -->
@@ -332,7 +507,7 @@
</template>
<script setup lang="ts">
import type { NocoDBSettings, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
import type { NocoDBSettings, RecaptchaConfig, RegistrationConfig, SMTPConfig } from '~/utils/types';
interface Props {
modelValue: boolean;
@@ -353,25 +528,34 @@ const activeTab = ref('nocodb');
const nocodbFormRef = ref();
const recaptchaFormRef = ref();
const registrationFormRef = ref();
const emailFormRef = ref();
// Form validity
const nocodbFormValid = ref(false);
const recaptchaFormValid = ref(false);
const registrationFormValid = ref(false);
const emailFormValid = ref(false);
// Loading states
const nocodbLoading = ref(false);
const recaptchaLoading = ref(false);
const registrationLoading = ref(false);
const emailLoading = ref(false);
const nocodbTestLoading = ref(false);
const emailTestLoading = ref(false);
// Display states
const showNocodbApiKey = ref(false);
const showRecaptchaSecret = ref(false);
const showEmailPassword = ref(false);
const showSuccessMessage = ref(false);
const successMessage = ref('');
const errorMessage = ref('');
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
const nocodbForm = ref<NocoDBSettings>({
@@ -392,6 +576,16 @@ const registrationForm = ref<RegistrationConfig>({
accountHolder: ''
});
const emailForm = ref<SMTPConfig>({
host: '',
port: 587,
secure: false,
username: '',
password: '',
fromAddress: '',
fromName: 'MonacoUSA Portal'
});
// Validation rules
const rules = {
required: (value: string | number) => {
@@ -405,6 +599,14 @@ const rules = {
positiveNumber: (value: 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) => {
if (!value) return true;
// Basic IBAN validation (length and format)
@@ -426,6 +628,8 @@ const isCurrentTabValid = computed(() => {
return recaptchaFormValid.value;
case 'registration':
return registrationFormValid.value;
case 'email':
return emailFormValid.value;
default:
return false;
}
@@ -439,6 +643,8 @@ const getCurrentTabName = computed(() => {
return 'reCAPTCHA Settings';
case 'registration':
return 'Registration Settings';
case 'email':
return 'Email Settings';
default:
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
const saveCurrentTab = async () => {
let formRef, loading, data, endpoint;
@@ -526,6 +779,12 @@ const saveCurrentTab = async () => {
data = registrationForm.value;
endpoint = '/api/admin/registration-config';
break;
case 'email':
formRef = emailFormRef.value;
loading = emailLoading;
data = emailForm.value;
endpoint = '/api/admin/smtp-config';
break;
default:
return;
}

View File

@@ -21,10 +21,13 @@
<v-menu
v-model="dropdownOpen"
:close-on-content-click="false"
location="bottom start"
:offset="isMobile ? 8 : 4"
min-width="280"
:transition="isMobile ? 'slide-y-transition' : 'fade-transition'"
:location="isMobile ? undefined : 'bottom start'"
:offset="isMobile ? 0 : 4"
:min-width="isMobile ? undefined : '280'"
:transition="isMobile ? 'fade-transition' : 'fade-transition'"
:no-click-animation="isMobile"
:attach="isMobile"
:strategy="isMobile ? 'fixed' : 'absolute'"
>
<template #activator="{ props: menuProps }">
<div
@@ -498,8 +501,9 @@ watch(() => props.modelValue, (newValue) => {
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
z-index: 1999;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Mobile Header */
@@ -553,15 +557,17 @@ watch(() => props.modelValue, (newValue) => {
.country-dropdown--mobile {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
top: 10% !important;
left: 5% !important;
right: 5% !important;
width: 90vw !important;
max-width: 400px !important;
max-height: 80vh !important;
margin: 0 auto !important;
border-radius: 16px !important;
z-index: 1000 !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2) !important;
z-index: 2000 !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
}
.country-list--mobile {